Commit ca1c3473 authored by GitLab Release Tools Bot's avatar GitLab Release Tools Bot

Merge branch 'security-grafana-stored-xss' into 'master'

Prevent XSS in admin grafana url setting

Closes #25

See merge request gitlab-org/security/gitlab!218
parents 2bd7bd9c b63d4512
...@@ -6,6 +6,9 @@ class ApplicationSetting < ApplicationRecord ...@@ -6,6 +6,9 @@ class ApplicationSetting < ApplicationRecord
include TokenAuthenticatable include TokenAuthenticatable
include ChronicDurationAttribute include ChronicDurationAttribute
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
'Admin Area > Settings > Metrics and profiling > Metrics - Grafana'
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
add_authentication_token_field :health_check_access_token add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token add_authentication_token_field :static_objects_external_storage_auth_token
...@@ -38,6 +41,14 @@ class ApplicationSetting < ApplicationRecord ...@@ -38,6 +41,14 @@ class ApplicationSetting < ApplicationRecord
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
validates :grafana_url,
system_hook_url: {
blocked_message: "is blocked: %{exception_message}. " + GRAFANA_URL_ERROR_MESSAGE
},
if: :grafana_url_absolute?
validate :validate_grafana_url
validates :uuid, presence: true validates :uuid, presence: true
validates :outbound_local_requests_whitelist, validates :outbound_local_requests_whitelist,
...@@ -359,6 +370,19 @@ class ApplicationSetting < ApplicationRecord ...@@ -359,6 +370,19 @@ class ApplicationSetting < ApplicationRecord
end end
after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') } after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') }
def validate_grafana_url
unless parsed_grafana_url
self.errors.add(
:grafana_url,
"must be a valid relative or absolute URL. #{GRAFANA_URL_ERROR_MESSAGE}"
)
end
end
def grafana_url_absolute?
parsed_grafana_url&.absolute?
end
def sourcegraph_url_is_com? def sourcegraph_url_is_com?
!!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/) !!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/)
end end
...@@ -391,6 +415,12 @@ class ApplicationSetting < ApplicationRecord ...@@ -391,6 +415,12 @@ class ApplicationSetting < ApplicationRecord
rescue RegexpError rescue RegexpError
errors.add(:email_restrictions, _('is not a valid regular expression')) errors.add(:email_restrictions, _('is not a valid regular expression'))
end end
private
def parsed_grafana_url
@parsed_grafana_url ||= Gitlab::Utils.parse_url(grafana_url)
end
end end
ApplicationSetting.prepend_if_ee('EE::ApplicationSetting') ApplicationSetting.prepend_if_ee('EE::ApplicationSetting')
...@@ -23,7 +23,8 @@ ...@@ -23,7 +23,8 @@
# protect against Server-side Request Forgery (SSRF), or check for the right port. # protect against Server-side Request Forgery (SSRF), or check for the right port.
# #
# Configuration options: # Configuration options:
# * <tt>message</tt> - A custom error message (default is: "must be a valid URL"). # * <tt>message</tt> - A custom error message, used when the URL is blank. (default is: "must be a valid URL").
# * <tt>blocked_message</tt> - A custom error message, used when the URL is blocked. Default: +'is blocked: %{exception_message}'+.
# * <tt>schemes</tt> - Array of URI schemes. Default: +['http', 'https']+ # * <tt>schemes</tt> - Array of URI schemes. Default: +['http', 'https']+
# * <tt>allow_localhost</tt> - Allow urls pointing to +localhost+. Default: +true+ # * <tt>allow_localhost</tt> - Allow urls pointing to +localhost+. Default: +true+
# * <tt>allow_local_network</tt> - Allow urls pointing to private network addresses. Default: +true+ # * <tt>allow_local_network</tt> - Allow urls pointing to private network addresses. Default: +true+
...@@ -59,7 +60,8 @@ class AddressableUrlValidator < ActiveModel::EachValidator ...@@ -59,7 +60,8 @@ class AddressableUrlValidator < ActiveModel::EachValidator
}.freeze }.freeze
DEFAULT_OPTIONS = BLOCKER_VALIDATE_OPTIONS.merge({ DEFAULT_OPTIONS = BLOCKER_VALIDATE_OPTIONS.merge({
message: 'must be a valid URL' message: 'must be a valid URL',
blocked_message: 'is blocked: %{exception_message}'
}).freeze }).freeze
def initialize(options) def initialize(options)
...@@ -80,7 +82,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator ...@@ -80,7 +82,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator
Gitlab::UrlBlocker.validate!(value, blocker_args) Gitlab::UrlBlocker.validate!(value, blocker_args)
rescue Gitlab::UrlBlocker::BlockedUrlError => e rescue Gitlab::UrlBlocker::BlockedUrlError => e
record.errors.add(attribute, "is blocked: #{e.message}") record.errors.add(attribute, options.fetch(:blocked_message) % { exception_message: e.message })
end end
private private
......
= form_for @application_setting, url: admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f| = form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting) = form_errors(@application_setting)
%fieldset %fieldset
......
...@@ -83,7 +83,7 @@ ...@@ -83,7 +83,7 @@
= _('Requests Profiles') = _('Requests Profiles')
- if Gitlab::CurrentSettings.current_application_settings.grafana_enabled? - if Gitlab::CurrentSettings.current_application_settings.grafana_enabled?
= nav_link do = nav_link do
= link_to Gitlab::CurrentSettings.current_application_settings.grafana_url, target: '_blank', title: _('Metrics Dashboard') do = link_to Gitlab::CurrentSettings.current_application_settings.grafana_url, target: '_blank', title: _('Metrics Dashboard'), rel: 'noopener noreferrer' do
%span %span
= _('Metrics Dashboard') = _('Metrics Dashboard')
= render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar' = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar'
......
---
title: Prevent XSS in admin grafana URL setting
merge_request:
author:
type: security
# frozen_string_literal: true
class CleanGrafanaUrl < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
execute(
<<-SQL
UPDATE
application_settings
SET
grafana_url = default
WHERE
position('javascript:' IN btrim(application_settings.grafana_url)) = 1
SQL
)
end
def down
# no-op
end
end
...@@ -117,8 +117,9 @@ If you have set up Grafana, you can enable a link to access it easily from the s ...@@ -117,8 +117,9 @@ If you have set up Grafana, you can enable a link to access it easily from the s
1. Expand **Metrics - Grafana**. 1. Expand **Metrics - Grafana**.
1. Check the "Enable access to Grafana" checkbox. 1. Check the "Enable access to Grafana" checkbox.
1. If Grafana is enabled through Omnibus GitLab and on the same server, 1. If Grafana is enabled through Omnibus GitLab and on the same server,
leave "Grafana URL" unchanged. In any other case, enter the full URL leave **Grafana URL** unchanged. It should be `/-/grafana`.
path of the Grafana instance.
In any other case, enter the full URL of the Grafana instance.
1. Click **Save changes**. 1. Click **Save changes**.
1. The new link will be available in the **Admin Area > Monitoring > Metrics Dashboard**. 1. The new link will be available in the **Admin Area > Monitoring > Metrics Dashboard**.
......
...@@ -146,5 +146,14 @@ module Gitlab ...@@ -146,5 +146,14 @@ module Gitlab
IPAddr.new(str) IPAddr.new(str)
rescue IPAddr::InvalidAddressError rescue IPAddr::InvalidAddressError
end end
# Converts a string to an Addressable::URI object.
# If the string is not a valid URI, it returns nil.
# Param uri_string should be a String object.
# This method returns an Addressable::URI object or nil.
def parse_url(uri_string)
Addressable::URI.parse(uri_string)
rescue Addressable::URI::InvalidURIError, TypeError
end
end end
end end
...@@ -291,4 +291,18 @@ describe Gitlab::Utils do ...@@ -291,4 +291,18 @@ describe Gitlab::Utils do
expect(described_class.string_to_ip_object('1:0:0:0:0:0:0:0/124')).to eq(IPAddr.new('1:0:0:0:0:0:0:0/124')) expect(described_class.string_to_ip_object('1:0:0:0:0:0:0:0/124')).to eq(IPAddr.new('1:0:0:0:0:0:0:0/124'))
end end
end end
describe '.parse_url' do
it 'returns Addressable::URI object' do
expect(described_class.parse_url('http://gitlab.com')).to be_instance_of(Addressable::URI)
end
it 'returns nil when URI cannot be parsed' do
expect(described_class.parse_url('://gitlab.com')).to be nil
end
it 'returns nil with invalid parameter' do
expect(described_class.parse_url(1)).to be nil
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'migrate', '20200214085940_clean_grafana_url.rb')
describe CleanGrafanaUrl, :migration do
let(:application_settings_table) { table(:application_settings) }
[
'javascript:alert(window.opener.document.location)',
' javascript:alert(window.opener.document.location)'
].each do |grafana_url|
it "sets grafana_url back to its default value when grafana_url is '#{grafana_url}'" do
application_settings = application_settings_table.create!(grafana_url: grafana_url)
migrate!
expect(application_settings.reload.grafana_url).to eq('/-/grafana')
end
end
['/-/grafana', '/some/relative/url', 'http://localhost:9000'].each do |grafana_url|
it "does not modify grafana_url when grafana_url is '#{grafana_url}'" do
application_settings = application_settings_table.create!(grafana_url: grafana_url)
migrate!
expect(application_settings.reload.grafana_url).to eq(grafana_url)
end
end
context 'when application_settings table has no rows' do
it 'does not fail' do
migrate!
end
end
end
...@@ -19,6 +19,7 @@ describe ApplicationSetting do ...@@ -19,6 +19,7 @@ describe ApplicationSetting do
let(:http) { 'http://example.com' } let(:http) { 'http://example.com' }
let(:https) { 'https://example.com' } let(:https) { 'https://example.com' }
let(:ftp) { 'ftp://example.com' } let(:ftp) { 'ftp://example.com' }
let(:javascript) { 'javascript:alert(window.opener.document.location)' }
it { is_expected.to allow_value(nil).for(:home_page_url) } it { is_expected.to allow_value(nil).for(:home_page_url) }
it { is_expected.to allow_value(http).for(:home_page_url) } it { is_expected.to allow_value(http).for(:home_page_url) }
...@@ -81,6 +82,53 @@ describe ApplicationSetting do ...@@ -81,6 +82,53 @@ describe ApplicationSetting do
it { is_expected.not_to allow_value('abc').for(:minimum_password_length) } it { is_expected.not_to allow_value('abc').for(:minimum_password_length) }
it { is_expected.to allow_value(10).for(:minimum_password_length) } it { is_expected.to allow_value(10).for(:minimum_password_length) }
context 'grafana_url validations' do
before do
subject.instance_variable_set(:@parsed_grafana_url, nil)
end
it { is_expected.to allow_value(http).for(:grafana_url) }
it { is_expected.to allow_value(https).for(:grafana_url) }
it { is_expected.not_to allow_value(ftp).for(:grafana_url) }
it { is_expected.not_to allow_value(javascript).for(:grafana_url) }
it { is_expected.to allow_value('/-/grafana').for(:grafana_url) }
it { is_expected.to allow_value('http://localhost:9000').for(:grafana_url) }
context 'when local URLs are not allowed in system hooks' do
before do
stub_application_setting(allow_local_requests_from_system_hooks: false)
end
it { is_expected.not_to allow_value('http://localhost:9000').for(:grafana_url) }
end
context 'with invalid grafana URL' do
it 'adds an error' do
subject.grafana_url = ' ' + http
expect(subject.save).to be false
expect(subject.errors[:grafana_url]).to eq([
'must be a valid relative or absolute URL. ' \
'Please check your Grafana URL setting in ' \
'Admin Area > Settings > Metrics and profiling > Metrics - Grafana'
])
end
end
context 'with blocked grafana URL' do
it 'adds an error' do
subject.grafana_url = javascript
expect(subject.save).to be false
expect(subject.errors[:grafana_url]).to eq([
'is blocked: Only allowed schemes are http, https. Please check your ' \
'Grafana URL setting in ' \
'Admin Area > Settings > Metrics and profiling > Metrics - Grafana'
])
end
end
end
context 'when snowplow is enabled' do context 'when snowplow is enabled' do
before do before do
setting.snowplow_enabled = true setting.snowplow_enabled = true
......
...@@ -5,6 +5,9 @@ require 'spec_helper' ...@@ -5,6 +5,9 @@ require 'spec_helper'
describe AddressableUrlValidator do describe AddressableUrlValidator do
let!(:badge) { build(:badge, link_url: 'http://www.example.com') } let!(:badge) { build(:badge, link_url: 'http://www.example.com') }
let(:validator) { described_class.new(validator_options.reverse_merge(attributes: [:link_url])) }
let(:validator_options) { {} }
subject { validator.validate(badge) } subject { validator.validate(badge) }
include_examples 'url validator examples', described_class::DEFAULT_OPTIONS[:schemes] include_examples 'url validator examples', described_class::DEFAULT_OPTIONS[:schemes]
...@@ -114,6 +117,19 @@ describe AddressableUrlValidator do ...@@ -114,6 +117,19 @@ describe AddressableUrlValidator do
end end
end end
context 'when blocked_message is set' do
let(:message) { 'is not allowed due to: %{exception_message}' }
let(:validator_options) { { blocked_message: message } }
it 'blocks url with provided error message' do
badge.link_url = 'javascript:alert(window.opener.document.location)'
subject
expect(badge.errors.first[1]).to eq 'is not allowed due to: Only allowed schemes are http, https'
end
end
context 'when allow_nil is set to true' do context 'when allow_nil is set to true' do
let(:validator) { described_class.new(attributes: [:link_url], allow_nil: true) } let(:validator) { described_class.new(attributes: [:link_url], allow_nil: 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