Skip to content
Snippets Groups Projects
Commit da77ff49 authored by Robert Hunt's avatar Robert Hunt Committed by GitLab Release Tools Bot
Browse files

Prevent code injection in Product Analytics funnels YAML

Merge branch 'security-prevent-code-injection-in-pa-funnels-17-2' into '17-2-stable-ee'

See merge request gitlab-org/security/gitlab!4430

Changelog: security
parent af196fe5
No related branches found
No related tags found
No related merge requests found
Showing
with 379 additions and 51 deletions
Loading
Loading
@@ -8,8 +8,11 @@ class Funnel
 
FUNNELS_ROOT_LOCATION = '.gitlab/analytics/funnels'
 
# This model is not used as a true ActiveRecord
# You must run .valid? wherever this model is used for these validations to be run
validates :name, presence: true
validates :seconds_to_convert, numericality: { only_integer: true, greater_than: 0 }
validate :check_steps_validity
 
def self.from_diff(diff, project:, sha: nil, commit: nil)
config_project = project.analytics_dashboards_configuration_project || project
Loading
Loading
@@ -98,7 +101,13 @@ def initialize(name:, project:, seconds_to_convert:, config_path:, config_projec
@previous_name = previous_name
end
 
def check_steps_validity
errors.add(:base, "Invalid steps") unless steps.all?(&:valid?)
end
def to_h
return unless valid?
{
name: name,
schema: to_sql,
Loading
Loading
@@ -126,6 +135,8 @@ def steps
end
 
def to_sql
return unless valid?
<<-SQL
SELECT
(SELECT max(derived_tstamp) FROM gitlab_project_#{project.id}.snowplow_events) as x,
Loading
Loading
Loading
Loading
@@ -4,9 +4,13 @@ module ProductAnalytics
class FunnelStep
include ActiveModel::Validations
 
attr_reader :name, :target, :action
attr_accessor :name, :target, :action
 
validates! :action, inclusion: { in: %w[pageview] }
# This model is not used as a true ActiveRecord
# You must run .valid? wherever this model is used for these validations to be run
validates :action, inclusion: { in: %w[pageview] }
validates :name, format: { with: /\A[\w\-]+\z/ }
validates :target, format: { with: %r{\A[\w\-./]+\z} }
 
def initialize(name:, target:, action:, funnel:)
@name = name
Loading
Loading
Loading
Loading
@@ -56,6 +56,8 @@ def funnels
end
 
funnels_to_create.each do |funnel|
next unless funnel.valid?
funnels_to_send << {
state: 'created',
name: funnel.name,
Loading
Loading
Loading
Loading
@@ -39,8 +39,10 @@ def funnel_files
end
 
def new_funnels
funnel_files.select(&:new_file).map do |file|
funnel_files.select(&:new_file).filter_map do |file|
funnel = ProductAnalytics::Funnel.from_diff(file, project: @project)
next unless funnel.valid?
{
state: 'created',
name: funnel.name,
Loading
Loading
@@ -51,8 +53,10 @@ def new_funnels
 
def updated_funnels
# if a file is not new or deleted, but is in a diff, we assume it is changed.
funnel_files.select { |f| !f.new_file && !f.deleted_file }.map do |file|
funnel_files.select { |f| !f.new_file && !f.deleted_file }.filter_map do |file|
funnel = ProductAnalytics::Funnel.from_diff(file, project: @project, commit: @commit)
next unless funnel.valid?
o = {
state: 'updated',
name: funnel.name,
Loading
Loading
Loading
Loading
@@ -207,6 +207,48 @@
end
end
 
trait :with_invalid_seconds_product_analytics_funnel do
repository
after(:create) do |project|
project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_seconds.yaml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_seconds.yaml')).read,
message: 'Add invalid seconds funnel definition',
branch_name: 'master'
)
end
end
trait :with_invalid_step_name_product_analytics_funnel do
repository
after(:create) do |project|
project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_step_name.yaml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_step_name.yaml')).read,
message: 'Add invalid step name funnel definition',
branch_name: 'master'
)
end
end
trait :with_invalid_step_target_product_analytics_funnel do
repository
after(:create) do |project|
project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_step_target.yaml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_step_target.yaml')).read,
message: 'Add invalid step target funnel definition',
branch_name: 'master'
)
end
end
trait(:allow_pipeline_trigger_approve_deployment) { allow_pipeline_trigger_approve_deployment { true } }
 
trait :verification_succeeded do
Loading
Loading
seconds_to_convert: 'invalid string'
steps:
- name: view_page_1
target: '/page1.html'
action: 'pageview'
- name: view_page_2
target: '/page2.html'
action: 'pageview'
seconds_to_convert: 3600
steps:
- name: '$step_name{}'
target: '/page1.html'
action: 'pageview'
- name: view_page_2
target: '/page2.html'
action: 'pageview'
seconds_to_convert: 3600
steps:
- name: view_page_1
target: '$step_name{}'
action: 'pageview'
- name: view_page_2
target: '/page2.html'
action: 'pageview'
Loading
Loading
@@ -5,6 +5,42 @@
RSpec.describe ProductAnalytics::Funnel, feature_category: :product_analytics_data_management do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :with_product_analytics_funnel, group: group) }
let_it_be(:project_invalid_seconds) { create(:project, :with_invalid_seconds_product_analytics_funnel, group: group) }
let_it_be(:project_invalid_step_name) do
create(:project, :with_invalid_step_name_product_analytics_funnel, group: group)
end
let_it_be(:project_invalid_step_target) do
create(:project, :with_invalid_step_target_product_analytics_funnel, group: group)
end
let(:query) do
<<-SQL
SELECT
(SELECT max(derived_tstamp) FROM gitlab_project_#{project.id}.snowplow_events) as x,
arrayJoin(range(1, 3)) AS level,
sumIf(c, user_level >= level) AS count
FROM
(SELECT
level AS user_level,
count(*) AS c
FROM (
SELECT
user_id,
windowFunnel(3600, 'strict_order')(toDateTime(derived_tstamp),
page_urlpath = '/page1.html', page_urlpath = '/page2.html'
) AS level
FROM gitlab_project_#{project.id}.snowplow_events
WHERE ${FILTER_PARAMS.funnel_example_1.date.filter('derived_tstamp')}
GROUP BY user_id
)
GROUP BY level
)
GROUP BY level
ORDER BY level ASC
SQL
end
 
before do
allow(Gitlab::CurrentSettings).to receive(:product_analytics_enabled?).and_return(true)
Loading
Loading
@@ -15,6 +51,24 @@
 
it { is_expected.to validate_numericality_of(:seconds_to_convert) }
 
context 'when the funnel has invalid seconds' do
subject(:funnel) { project_invalid_seconds.product_analytics_funnels.first }
it { is_expected.to be_invalid }
end
context 'when the funnel has invalid step name' do
subject(:funnel) { project_invalid_step_name.product_analytics_funnels.first }
it { is_expected.to be_invalid }
end
context 'when the funnel has invalid step target' do
subject(:funnel) { project_invalid_step_target.product_analytics_funnels.first }
it { is_expected.to be_invalid }
end
describe '.for_project' do
subject(:funnels) { described_class.for_project(project) }
 
Loading
Loading
@@ -138,37 +192,92 @@
end
end
 
describe '#to_h' do
subject { project.product_analytics_funnels.first.to_h }
let(:object) do
{
name: 'funnel_example_1',
schema: query,
steps: ["page_urlpath = '/page1.html'", "page_urlpath = '/page2.html'"]
}
end
it { is_expected.to eq(object) }
context 'when the funnel has invalid seconds' do
subject { project_invalid_seconds.product_analytics_funnels.first.to_h }
it { is_expected.to eq(nil) }
end
context 'when the funnel has invalid step name' do
subject { project_invalid_step_name.product_analytics_funnels.first.to_h }
it { is_expected.to eq(nil) }
end
context 'when the funnel has invalid step target' do
subject { project_invalid_step_target.product_analytics_funnels.first.to_h }
it { is_expected.to eq(nil) }
end
end
describe '#to_json' do
subject { project.product_analytics_funnels.first.to_json }
let(:object) do
{
name: 'funnel_example_1',
schema: query,
steps: ["page_urlpath = '/page1.html'", "page_urlpath = '/page2.html'"]
}.to_json
end
it { is_expected.to eq(object) }
context 'when the funnel has invalid seconds' do
subject { project_invalid_seconds.product_analytics_funnels.first.to_json }
it { is_expected.to eq('null') }
end
context 'when the funnel has invalid step name' do
subject { project_invalid_step_name.product_analytics_funnels.first.to_json }
it { is_expected.to eq('null') }
end
context 'when the funnel has invalid step target' do
subject { project_invalid_step_target.product_analytics_funnels.first.to_json }
it { is_expected.to eq('null') }
end
end
describe '#to_sql' do
subject { project.product_analytics_funnels.first.to_sql }
 
let(:query) do
<<-SQL
SELECT
(SELECT max(derived_tstamp) FROM gitlab_project_#{project.id}.snowplow_events) as x,
arrayJoin(range(1, 3)) AS level,
sumIf(c, user_level >= level) AS count
FROM
(SELECT
level AS user_level,
count(*) AS c
FROM (
SELECT
user_id,
windowFunnel(3600, 'strict_order')(toDateTime(derived_tstamp),
page_urlpath = '/page1.html', page_urlpath = '/page2.html'
) AS level
FROM gitlab_project_#{project.id}.snowplow_events
WHERE ${FILTER_PARAMS.funnel_example_1.date.filter('derived_tstamp')}
GROUP BY user_id
)
GROUP BY level
)
GROUP BY level
ORDER BY level ASC
SQL
it { is_expected.to eq(query) }
context 'when the funnel has invalid seconds' do
subject { project_invalid_seconds.product_analytics_funnels.first.to_sql }
it { is_expected.to eq(nil) }
end
 
it { is_expected.to eq(query) }
context 'when the funnel has invalid step name' do
subject { project_invalid_step_name.product_analytics_funnels.first.to_sql }
it { is_expected.to eq(nil) }
end
context 'when the funnel has invalid step target' do
subject { project_invalid_step_target.product_analytics_funnels.first.to_sql }
it { is_expected.to eq(nil) }
end
end
 
private
Loading
Loading
Loading
Loading
@@ -13,7 +13,14 @@
)
end
 
let(:funnel_step) { described_class.new(name: 'test', target: '/page1.html', action: 'pageview', funnel: funnel) }
subject(:funnel_step) { described_class.new(name: 'test', target: '/page1.html', action: 'pageview', funnel: funnel) }
it { is_expected.to validate_inclusion_of(:action).in_array(%w[pageview]) }
it { is_expected.not_to allow_value('test').for(:action) }
it { is_expected.to allow_value('Test_name-format01').for(:name) }
it { is_expected.not_to allow_value('${(test(){})}').for(:name) }
it { is_expected.to allow_value('section/subsection/page.html').for(:target) }
it { is_expected.not_to allow_value('${(test(){})}').for(:target) }
 
describe '#initialize' do
it 'has a name' do
Loading
Loading
@@ -27,16 +34,6 @@
it 'has an action' do
expect(funnel_step.action).to eq('pageview')
end
context 'when action is not a valid type' do
let(:funnel_step) do
described_class.new(name: 'test', target: '/page1.html', action: 'invalid', funnel: funnel).valid?
end
it 'raises an error' do
expect { funnel_step }.to raise_error(ActiveModel::StrictValidationFailed)
end
end
end
 
describe '#to_h' do
Loading
Loading
Loading
Loading
@@ -21,6 +21,20 @@
message: 'Add funnel',
branch_name: 'master'
)
previous_custom_dashboard_project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_seconds.yml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_seconds.yaml')),
message: 'Add invalid seconds funnel definition',
branch_name: 'master'
)
previous_custom_dashboard_project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_step.yml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_step_name.yaml')),
message: 'Add invalid step name funnel definition',
branch_name: 'master'
)
new_custom_dashboard_project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/example1.yml',
Loading
Loading
@@ -28,6 +42,20 @@
message: 'Add funnel',
branch_name: 'master'
)
new_custom_dashboard_project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_seconds.yml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_seconds.yaml')),
message: 'Add invalid seconds funnel definition',
branch_name: 'master'
)
new_custom_dashboard_project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_step.yml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_step_name.yaml')),
message: 'Add invalid step name funnel definition',
branch_name: 'master'
)
end
 
describe "perform" do
Loading
Loading
@@ -66,9 +94,11 @@
expect(payload).to match(
a_hash_including(
project_ids: ["gitlab_project_#{project.id}"],
funnels: [a_hash_including(
state: "deleted"
)]
funnels: [
{ name: "example1", state: "deleted" },
{ name: "funnel_example_invalid_seconds", state: "deleted" },
{ name: "funnel_example_invalid_step", state: "deleted" }
]
)
)
end
Loading
Loading
@@ -90,11 +120,15 @@
expect(payload).to match(
a_hash_including(
project_ids: ["gitlab_project_#{project.id}"],
funnels: [a_hash_including(
state: "deleted"
), a_hash_including(
state: "created"
)]
funnels: [
{ name: "example1", state: "deleted" },
{ name: "funnel_example_invalid_seconds", state: "deleted" },
{ name: "funnel_example_invalid_step", state: "deleted" },
a_hash_including(
name: "example1",
state: "created"
)
]
)
)
end
Loading
Loading
Loading
Loading
@@ -27,6 +27,22 @@
 
worker
end
context 'when the new funnel is invalid' do
before do
create_invalid_funnel
end
after do
delete_funnel("funnel_example_invalid_step.yml")
end
it 'does not attempt to post to the API' do
expect(Gitlab::HTTP).not_to receive(:post)
worker
end
end
end
 
context 'when an updated funnel is in the commit' do
Loading
Loading
@@ -51,12 +67,29 @@
 
worker
end
context 'when the updated funnel is invalid' do
before do
create_invalid_funnel
update_invalid_funnel
end
after do
delete_funnel("funnel_example_invalid_step.yml")
end
it 'does not attempt to post to the API' do
expect(Gitlab::HTTP).not_to receive(:post)
worker
end
end
end
 
context 'when an renamed funnel is in the commit' do
before do
create_valid_funnel
rename_funnel
rename_valid_funnel
end
 
after do
Loading
Loading
@@ -75,6 +108,23 @@
 
worker
end
context 'when the renamed funnel is invalid' do
before do
create_invalid_funnel
rename_invalid_funnel
end
after do
delete_funnel("funnel_example_invalid_seconds.yml")
end
it 'does not attempt to post to the API' do
expect(Gitlab::HTTP).not_to receive(:post)
worker
end
end
end
 
context 'when a deleted funnel is in the commit' do
Loading
Loading
@@ -95,6 +145,26 @@
 
worker
end
context 'when the deleted funnel is invalid' do
before do
create_invalid_funnel
delete_funnel("funnel_example_invalid_step.yml")
end
it 'is successful' do
url_to_projects_regex.each do |url, projects_regex|
expect(Gitlab::HTTP).to receive(:post)
.with(URI.parse(url.to_s), {
allow_local_requests: true,
body: Regexp.new(projects_regex.source + /.*\"state\":\"deleted\"/.source)
}).once
.and_return(instance_double("HTTParty::Response", body: { result: 'success' }))
end
worker
end
end
end
 
context 'when no new or updated funnels are in the commit' do
Loading
Loading
@@ -273,7 +343,17 @@ def create_valid_funnel
)
end
 
def rename_funnel
def create_invalid_funnel
project.repository.create_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_step.yml',
File.read(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_step_name.yaml')),
message: 'Add invalid funnel',
branch_name: 'master'
)
end
def rename_valid_funnel
project.repository.update_file(
project.creator,
'.gitlab/analytics/funnels/example2.yml',
Loading
Loading
@@ -284,6 +364,17 @@ def rename_funnel
)
end
 
def rename_invalid_funnel
project.repository.update_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_seconds.yml',
File.read(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_seconds.yaml')),
message: 'Rename invalid funnel',
branch_name: 'master',
previous_path: '.gitlab/analytics/funnels/funnel_example_invalid_step.yml'
)
end
def update_funnel
project.repository.update_file(
project.creator,
Loading
Loading
@@ -294,6 +385,16 @@ def update_funnel
)
end
 
def update_invalid_funnel
project.repository.update_file(
project.creator,
'.gitlab/analytics/funnels/funnel_example_invalid_step.yml',
File.read(Rails.root.join('ee/spec/fixtures/product_analytics/funnel_example_invalid_step_name.yaml')),
message: 'Update invalid funnel',
branch_name: 'master'
)
end
def delete_funnel(filename)
project.repository.delete_file(
project.creator,
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment