Commit b4154340 authored by Mark Chao's avatar Mark Chao

ES: fix note leak for private feature

Note has 4 parent types. Since they are different features, they
can't share project ids filtering.

Each "has_parent" query must be "AND"ed with its respective filtered
project ids. The 4 queries are then "OR"ed.
parent 53869dc2
---
title: Fix private comment Elasticsearch leak on project search scope
merge_request:
author:
type: security
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
module Elastic module Elastic
module Latest module Latest
class NoteClassProxy < ApplicationClassProxy class NoteClassProxy < ApplicationClassProxy
extend ::Gitlab::Utils::Override
def es_type def es_type
'note' 'note'
end end
...@@ -60,6 +62,98 @@ module Elastic ...@@ -60,6 +62,98 @@ module Elastic
query_hash[:query][:bool][:filter] << filter query_hash[:query][:bool][:filter] << filter
query_hash query_hash
end end
def noteable_type_to_feature
{
'Issue': :issues,
'MergeRequest': :merge_requests,
'Snippet': :snippets,
'Commit': :repository
}
end
override :project_ids_filter
def project_ids_filter(query_hash, options)
query_hash[:query][:bool][:filter] ||= []
project_query = project_ids_query(
options[:current_user],
options[:project_ids],
options[:public_and_internal_projects],
options[:features]
)
filters = {
bool: {
should: []
}
}
# For each project id filter,
# extract the noteable_type attribute,
# and use that to filter at Note level.
project_query[:should].flatten.each do |condition|
noteable_type = condition.delete(:noteable_type).to_s
filters[:bool][:should] << {
bool: {
must: [
{
has_parent: {
parent_type: "project",
query: {
bool: {
should: condition
}
}
}
},
{ term: { noteable_type: noteable_type } }
]
}
}
end
query_hash[:query][:bool][:filter] << filters
query_hash
end
# Query notes based on the various feature permission of the noteable_type.
# Appends `noteable_type` (which will be removed in project_ids_filter)
# for base model filtering.
override :pick_projects_by_membership
def pick_projects_by_membership(project_ids, user, _ = nil)
noteable_type_to_feature.map do |noteable_type, feature|
condition =
if project_ids == :any
{ term: { visibility_level: Project::PRIVATE } }
else
{ terms: { id: filter_ids_by_feature(project_ids, user, feature) } }
end
limit =
{ terms: { "#{feature}_access_level" => [::ProjectFeature::ENABLED, ::ProjectFeature::PRIVATE] } }
{ bool: { filter: [condition, limit] }, noteable_type: noteable_type }
end
end
# Query notes based on the various feature permission of the noteable_type.
# Appends `noteable_type` (which will be removed in project_ids_filter)
# for base model filtering.
override :limit_by_feature
def limit_by_feature(condition, _ = nil, include_members_only:)
noteable_type_to_feature.map do |noteable_type, feature|
limit =
if include_members_only
{ terms: { "#{feature}_access_level" => [::ProjectFeature::ENABLED, ::ProjectFeature::PRIVATE] } }
else
{ term: { "#{feature}_access_level" => ::ProjectFeature::ENABLED } }
end
{ bool: { filter: [condition, limit] }, noteable_type: noteable_type }
end
end
end end
end end
end end
...@@ -235,7 +235,7 @@ describe Gitlab::Elastic::SearchResults, :elastic, :sidekiq_might_not_need_inlin ...@@ -235,7 +235,7 @@ describe Gitlab::Elastic::SearchResults, :elastic, :sidekiq_might_not_need_inlin
it 'redacts commit comments when user is a guest on a private project' do it 'redacts commit comments when user is a guest on a private project' do
project_1.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE) project_1.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
project_1.add_guest(user) project_1.add_guest(user)
note_on_commit = create( create(
:note_on_commit, :note_on_commit,
project: project_1, project: project_1,
note: 'foo note on commit' note: 'foo note on commit'
...@@ -245,13 +245,9 @@ describe Gitlab::Elastic::SearchResults, :elastic, :sidekiq_might_not_need_inlin ...@@ -245,13 +245,9 @@ describe Gitlab::Elastic::SearchResults, :elastic, :sidekiq_might_not_need_inlin
results = described_class.new(user, 'foo', limit_project_ids) results = described_class.new(user, 'foo', limit_project_ids)
expect(results.send(:logger)) expect(results.send(:logger)).not_to receive(:error)
.to receive(:error)
.with(hash_including(message: "redacted_search_results", filtered: array_including([
{ class_name: "Note", id: note_on_commit.id, ability: :read_note }
])))
expect(results.notes_count).to eq(3) # 3 because redacting only happens when we instantiate the results expect(results.notes_count).to eq(2)
expect(results.objects('notes')).to match_array([@note_1, @note_2]) expect(results.objects('notes')).to match_array([@note_1, @note_2])
end end
end end
......
...@@ -36,24 +36,23 @@ describe Note, :elastic do ...@@ -36,24 +36,23 @@ describe Note, :elastic do
end end
end end
it "searches notes", :sidekiq_might_not_need_inline do it "searches notes", :sidekiq_inline do
issue = create :issue project = create :project, :public
issue = create :issue, project: project
Sidekiq::Testing.inline! do note = create :note, note: 'bla-bla term1', project: issue.project
create :note, note: 'bla-bla term1', project: issue.project create :note, project: issue.project
create :note, project: issue.project
# The note in the project you have no access to except as an administrator # The note in the project you have no access to except as an administrator
create :note, note: 'bla-bla term2' outside_note = create :note, note: 'bla-bla term2'
Gitlab::Elastic::Helper.refresh_index Gitlab::Elastic::Helper.refresh_index
end
options = { project_ids: [issue.project.id] } options = { project_ids: [issue.project.id] }
expect(described_class.elastic_search('term1 | term2', options: options).total_count).to eq(1) expect(described_class.elastic_search('term1 | term2', options: options).records).to contain_exactly(note)
expect(described_class.elastic_search('bla-bla', options: options).total_count).to eq(1) expect(described_class.elastic_search('bla-bla', options: options).records).to contain_exactly(note)
expect(described_class.elastic_search('bla-bla', options: { project_ids: :any }).total_count).to eq(2) expect(described_class.elastic_search('bla-bla', options: { project_ids: :any }).records).to contain_exactly(outside_note)
end end
it "indexes && searches diff notes" do it "indexes && searches diff notes" do
...@@ -65,6 +64,10 @@ describe Note, :elastic do ...@@ -65,6 +64,10 @@ describe Note, :elastic do
notes << create(:legacy_diff_note_on_merge_request, note: "term") notes << create(:legacy_diff_note_on_merge_request, note: "term")
notes << create(:legacy_diff_note_on_commit, note: "term") notes << create(:legacy_diff_note_on_commit, note: "term")
notes.each do |note|
note.project.update!(visibility: Gitlab::VisibilityLevel::PUBLIC)
end
Gitlab::Elastic::Helper.refresh_index Gitlab::Elastic::Helper.refresh_index
end end
...@@ -138,6 +141,7 @@ describe Note, :elastic do ...@@ -138,6 +141,7 @@ describe Note, :elastic do
it "finds note when user is authorized to see it", :sidekiq_might_not_need_inline do it "finds note when user is authorized to see it", :sidekiq_might_not_need_inline do
user = create :user user = create :user
issue = create :issue, :confidential, author: user issue = create :issue, :confidential, author: user
issue.project.add_guest user
Sidekiq::Testing.inline! do Sidekiq::Testing.inline! do
create_notes_for(issue, 'bla-bla term') create_notes_for(issue, 'bla-bla term')
......
...@@ -277,7 +277,7 @@ describe Elastic::IndexRecordService, :elastic do ...@@ -277,7 +277,7 @@ describe Elastic::IndexRecordService, :elastic do
note = nil note = nil
Sidekiq::Testing.inline! do Sidekiq::Testing.inline! do
project = create :project, :repository project = create :project, :repository, :public
note = create :note, project: project, note: 'note_1' note = create :note, project: project, note: 'note_1'
Gitlab::Elastic::Helper.refresh_index Gitlab::Elastic::Helper.refresh_index
end end
...@@ -323,16 +323,17 @@ describe Elastic::IndexRecordService, :elastic do ...@@ -323,16 +323,17 @@ describe Elastic::IndexRecordService, :elastic do
context 'when updating an Issue' do context 'when updating an Issue' do
context 'when changing the confidential value' do context 'when changing the confidential value' do
it 'updates issue notes excluding system notes' do it 'updates issue notes excluding system notes' do
project = create(:project, :public)
issue = nil issue = nil
Sidekiq::Testing.disable! do Sidekiq::Testing.disable! do
issue = create(:issue, confidential: false) issue = create(:issue, project: project, confidential: false)
subject.execute(issue.project, true) subject.execute(project, true)
subject.execute(issue, false) subject.execute(issue, false)
create(:note, note: 'the_normal_note', noteable: issue, project: issue.project) create(:note, note: 'the_normal_note', noteable: issue, project: project)
create(:note, note: 'the_system_note', system: true, noteable: issue, project: issue.project) create(:note, note: 'the_system_note', system: true, noteable: issue, project: project)
end end
options = { project_ids: [issue.project.id] } options = { project_ids: [project.id] }
Sidekiq::Testing.inline! do Sidekiq::Testing.inline! do
expect(subject.execute(issue, false, 'changed_fields' => ['confidential'])).to eq(true) expect(subject.execute(issue, false, 'changed_fields' => ['confidential'])).to eq(true)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment