Merge branch 'security-6881-project-group-approvers-leaks-private-group-info-ce' into 'master'

[master] CE: Project group approvers leaks private group info

See merge request gitlab/gitlabhq!2488
This commit is contained in:
Bob Van Landuyt 2018-10-01 16:45:01 +00:00
commit b93f1d3cf8
20 changed files with 160 additions and 45 deletions

View file

@ -106,6 +106,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@commits = set_commits_for_rendering(@merge_request.commits) @commits = set_commits_for_rendering(@merge_request.commits)
@commit = @merge_request.diff_head_commit @commit = @merge_request.diff_head_commit
# FIXME: We have to assign a presenter to another instance variable
# due to class_name checks being made with issuable classes
@mr_presenter = @merge_request.present(current_user: current_user)
@labels = LabelsFinder.new(current_user, project_id: @project.id).execute @labels = LabelsFinder.new(current_user, project_id: @project.id).execute
set_pipeline_variables set_pipeline_variables

View file

@ -333,6 +333,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@target_project = @merge_request.target_project @target_project = @merge_request.target_project
@target_branches = @merge_request.target_project.repository.branch_names @target_branches = @merge_request.target_project.repository.branch_names
@noteable = @merge_request @noteable = @merge_request
# FIXME: We have to assign a presenter to another instance variable
# due to class_name checks being made with issuable classes
@mr_presenter = @merge_request.present(current_user: current_user)
end end
def finder_type def finder_type

View file

@ -16,6 +16,7 @@ class ProjectsController < Projects::ApplicationController
before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
before_action :present_project, only: [:edit]
# Authorize # Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
@ -433,4 +434,8 @@ class ProjectsController < Projects::ApplicationController
def whitelist_query_limiting def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440') Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440')
end end
def present_project
@project = @project.present(current_user: current_user)
end
end end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class JoinedGroupsFinder < UnionFinder class JoinedGroupsFinder
def initialize(user) def initialize(user)
@user = user @user = user
end end
@ -8,19 +8,8 @@ class JoinedGroupsFinder < UnionFinder
# Finds the groups of the source user, optionally limited to those visible to # Finds the groups of the source user, optionally limited to those visible to
# the current user. # the current user.
def execute(current_user = nil) def execute(current_user = nil)
segments = all_groups(current_user) @user.authorized_groups
.public_or_visible_to_user(current_user)
find_union(segments, Group).order_id_desc .order_id_desc
end
private
def all_groups(current_user)
groups = []
groups << @user.authorized_groups.visible_to_user(current_user) if current_user
groups << @user.authorized_groups.public_to_user(current_user)
groups
end end
end end

View file

@ -3,13 +3,14 @@
module Emails module Emails
module MergeRequests module MergeRequests
def new_merge_request_email(recipient_id, merge_request_id, reason = nil) def new_merge_request_email(recipient_id, merge_request_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id) setup_merge_request_mail(merge_request_id, recipient_id, present: true)
mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason)) mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason))
end end
def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id) setup_merge_request_mail(merge_request_id, recipient_id, present: true)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end end
@ -75,11 +76,16 @@ module Emails
private private
def setup_merge_request_mail(merge_request_id, recipient_id) def setup_merge_request_mail(merge_request_id, recipient_id, present: false)
@merge_request = MergeRequest.find(merge_request_id) @merge_request = MergeRequest.find(merge_request_id)
@project = @merge_request.project @project = @merge_request.project
@target_url = project_merge_request_url(@project, @merge_request) @target_url = project_merge_request_url(@project, @merge_request)
if present
recipient = User.find(recipient_id)
@mr_presenter = @merge_request.present(current_user: recipient)
end
@sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key)
end end

View file

@ -82,8 +82,17 @@ class Group < Namespace
User.reference_pattern User.reference_pattern
end end
def visible_to_user(user) # WARNING: This method should never be used on its own
where(id: user.authorized_groups.select(:id).reorder(nil)) # please do make sure the number of rows you are filtering is small
# enough for this query
def public_or_visible_to_user(user)
return public_to_user unless user
public_for_user = public_to_user_arel(user)
visible_for_user = visible_to_user_arel(user)
public_or_visible = public_for_user.or(visible_for_user)
where(public_or_visible)
end end
def select_for_project_authorization def select_for_project_authorization
@ -95,6 +104,23 @@ class Group < Namespace
super super
end end
end end
private
def public_to_user_arel(user)
self.arel_table[:visibility_level]
.in(Gitlab::VisibilityLevel.levels_for_user(user))
end
def visible_to_user_arel(user)
groups_table = self.arel_table
authorized_groups = user.authorized_groups.as('authorized')
groups_table.project(1)
.from(authorized_groups)
.where(authorized_groups[:id].eq(groups_table[:id]))
.exists
end
end end
# Overrides notification_settings has_many association # Overrides notification_settings has_many association

View file

@ -6,6 +6,7 @@ class MergeRequest < ActiveRecord::Base
include Issuable include Issuable
include Noteable include Noteable
include Referable include Referable
include Presentable
include IgnorableColumn include IgnorableColumn
include TimeTrackable include TimeTrackable
include ManualInverseAssociation include ManualInverseAssociation

View file

@ -674,11 +674,13 @@ class User < ActiveRecord::Base
# Returns the groups a user has access to, either through a membership or a project authorization # Returns the groups a user has access to, either through a membership or a project authorization
def authorized_groups def authorized_groups
Group.unscoped do
Group.from_union([ Group.from_union([
groups, groups,
authorized_projects.joins(:namespace).select('namespaces.*') authorized_projects.joins(:namespace).select('namespaces.*')
]) ])
end end
end
# Returns the groups a user is a member of, either directly or through a parent group # Returns the groups a user is a member of, either directly or through a parent group
def membership_groups def membership_groups

View file

@ -9,7 +9,7 @@
%p %p
Assignee: #{@merge_request.assignee_name} Assignee: #{@merge_request.assignee_name}
= render_if_exists 'notify/merge_request_approvers', merge_request: @merge_request = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
- if @merge_request.description - if @merge_request.description
%div %div

View file

@ -5,6 +5,6 @@ New Merge Request <%= @merge_request.to_reference %>
<%= merge_path_description(@merge_request, 'to') %> <%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %> Author: <%= @merge_request.author_name %>
Assignee: <%= @merge_request.assignee_name %> Assignee: <%= @merge_request.assignee_name %>
<%= render_if_exists 'notify/merge_request_approvers', merge_request: @merge_request %> <%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %>
<%= @merge_request.description %> <%= @merge_request.description %>

View file

@ -1,4 +1,4 @@
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], = form_for [@project.namespace.becomes(Namespace), @project, @merge_request],
html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' }, html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' },
data: { markdown_version: @merge_request.cached_markdown_version } do |f| data: { markdown_version: @merge_request.cached_markdown_version } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request = render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter

View file

@ -11,7 +11,7 @@
= link_to 'Change branches', mr_change_branches_path(@merge_request) = link_to 'Change branches', mr_change_branches_path(@merge_request)
%hr %hr
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter
= f.hidden_field :source_project_id = f.hidden_field :source_project_id
= f.hidden_field :source_branch = f.hidden_field :source_branch
= f.hidden_field :target_project_id = f.hidden_field :target_project_id

View file

@ -1,6 +1,7 @@
- form = local_assigns.fetch(:f) - form = local_assigns.fetch(:f)
- commits = local_assigns[:commits] - commits = local_assigns[:commits]
- project = @target_project || @project - project = @target_project || @project
- presenter = local_assigns.fetch(:presenter, nil)
= form_errors(issuable) = form_errors(issuable)
@ -29,7 +30,7 @@
= render 'shared/issuable/form/metadata', issuable: issuable, form: form = render 'shared/issuable/form/metadata', issuable: issuable, form: form
= render_if_exists 'shared/issuable/approvals', issuable: issuable, form: form = render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
#
module RuboCop
module Cop
# Cop that blacklists the usage of Group.public_or_visible_to_user
class GroupPublicOrVisibleToUser < RuboCop::Cop::Cop
MSG = '`Group.public_or_visible_to_user` should be used with extreme care. ' \
'Please ensure that you are not using it on its own and that the amount ' \
'of rows being filtered is reasonable.'
def_node_matcher :public_or_visible_to_user?, <<~PATTERN
(send (const nil? :Group) :public_or_visible_to_user ...)
PATTERN
def on_send(node)
return unless public_or_visible_to_user?(node)
add_offense(node, location: :expression)
end
end
end
end

View file

@ -38,3 +38,4 @@ require_relative 'cop/code_reuse/service_class'
require_relative 'cop/code_reuse/presenter' require_relative 'cop/code_reuse/presenter'
require_relative 'cop/code_reuse/serializer' require_relative 'cop/code_reuse/serializer'
require_relative 'cop/code_reuse/active_record' require_relative 'cop/code_reuse/active_record'
require_relative 'cop/group_public_or_visible_to_user'

View file

@ -169,22 +169,42 @@ describe Group do
end end
end end
describe '.visible_to_user' do describe '.public_or_visible_to_user' do
let!(:group) { create(:group) } let!(:private_group) { create(:group, :private) }
let!(:internal_group) { create(:group, :internal) }
subject { described_class.public_or_visible_to_user(user) }
context 'when user is nil' do
let!(:user) { nil }
it { is_expected.to match_array([group]) }
end
context 'when user' do
let!(:user) { create(:user) } let!(:user) { create(:user) }
subject { described_class.visible_to_user(user) } context 'when user does not have access to any private group' do
it { is_expected.to match_array([internal_group, group]) }
end
describe 'when the user has access to a group' do context 'when user is a member of private group' do
before do before do
group.add_user(user, Gitlab::Access::MAINTAINER) private_group.add_user(user, Gitlab::Access::DEVELOPER)
end end
it { is_expected.to eq([group]) } it { is_expected.to match_array([private_group, internal_group, group]) }
end end
describe 'when the user does not have access to any groups' do context 'when user is a member of private subgroup', :postgresql do
it { is_expected.to eq([]) } let!(:private_subgroup) { create(:group, :private, parent: private_group) }
before do
private_subgroup.add_user(user, Gitlab::Access::DEVELOPER)
end
it { is_expected.to match_array([private_subgroup, internal_group, group]) }
end
end end
end end

View file

@ -155,7 +155,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(response_groups).to eq(Group.visible_to_user(user1).order(:name).pluck(:name)) expect(response_groups).to eq(groups_visible_to_user(user1).order(:name).pluck(:name))
end end
it "sorts in descending order when passed" do it "sorts in descending order when passed" do
@ -164,7 +164,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(response_groups).to eq(Group.visible_to_user(user1).order(name: :desc).pluck(:name)) expect(response_groups).to eq(groups_visible_to_user(user1).order(name: :desc).pluck(:name))
end end
it "sorts by path in order_by param" do it "sorts by path in order_by param" do
@ -173,7 +173,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(response_groups).to eq(Group.visible_to_user(user1).order(:path).pluck(:name)) expect(response_groups).to eq(groups_visible_to_user(user1).order(:path).pluck(:name))
end end
it "sorts by id in the order_by param" do it "sorts by id in the order_by param" do
@ -182,7 +182,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(response_groups).to eq(Group.visible_to_user(user1).order(:id).pluck(:name)) expect(response_groups).to eq(groups_visible_to_user(user1).order(:id).pluck(:name))
end end
it "sorts also by descending id with pagination fix" do it "sorts also by descending id with pagination fix" do
@ -191,7 +191,7 @@ describe API::Groups do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers expect(response).to include_pagination_headers
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(response_groups).to eq(Group.visible_to_user(user1).order(id: :desc).pluck(:name)) expect(response_groups).to eq(groups_visible_to_user(user1).order(id: :desc).pluck(:name))
end end
it "sorts identical keys by id for good pagination" do it "sorts identical keys by id for good pagination" do
@ -211,6 +211,10 @@ describe API::Groups do
expect(json_response).to be_an Array expect(json_response).to be_an Array
expect(response_groups_ids).to eq(Group.select { |group| group['name'] == 'same-name' }.map { |group| group['id'] }.sort) expect(response_groups_ids).to eq(Group.select { |group| group['name'] == 'same-name' }.map { |group| group['id'] }.sort)
end end
def groups_visible_to_user(user)
Group.where(id: user.authorized_groups.select(:id).reorder(nil))
end
end end
context 'when using owned in the request' do context 'when using owned in the request' do

View file

@ -0,0 +1,28 @@
require 'spec_helper'
require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../rubocop/cop/group_public_or_visible_to_user'
describe RuboCop::Cop::GroupPublicOrVisibleToUser do
include CopHelper
subject(:cop) { described_class.new }
it 'flags the use of Group.public_or_visible_to_user with a constant receiver' do
inspect_source('Group.public_or_visible_to_user')
expect(cop.offenses.size).to eq(1)
end
it 'does not flat the use of public_or_visible_to_user with a constant that is not Group' do
inspect_source('Project.public_or_visible_to_user')
expect(cop.offenses.size).to eq(0)
end
it 'does not flag the use of Group.public_or_visible_to_user with a send receiver' do
inspect_source('foo.public_or_visible_to_user')
expect(cop.offenses.size).to eq(0)
end
end

View file

@ -12,6 +12,7 @@ describe 'projects/merge_requests/creations/_new_submit.html.haml' do
assign(:hidden_commit_count, 0) assign(:hidden_commit_count, 0)
assign(:total_commit_count, merge_request.commits.count) assign(:total_commit_count, merge_request.commits.count)
assign(:project, merge_request.target_project) assign(:project, merge_request.target_project)
assign(:mr_presenter, merge_request.present(current_user: merge_request.author))
allow(view).to receive(:can?).and_return(true) allow(view).to receive(:can?).and_return(true)
allow(view).to receive(:url_for).and_return('#') allow(view).to receive(:url_for).and_return('#')

View file

@ -24,6 +24,7 @@ describe 'projects/merge_requests/edit.html.haml' do
before do before do
assign(:project, project) assign(:project, project)
assign(:merge_request, closed_merge_request) assign(:merge_request, closed_merge_request)
assign(:mr_presenter, closed_merge_request.present(current_user: user))
allow(view).to receive(:can?).and_return(true) allow(view).to receive(:can?).and_return(true)
allow(view).to receive(:current_user) allow(view).to receive(:current_user)