summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStan Hu <stanhu@gmail.com>2019-08-05 23:14:32 -0700
committerStan Hu <stanhu@gmail.com>2019-08-06 08:37:24 -0700
commit156c6c4d62c12e531be972e7224724a43f5e568d (patch)
treecd7c2339782815c842bc3205bb420babcb43ec42
parentfa216b0e86433192ba4e39a05f42217fb4685173 (diff)
downloadgitlab-ce-sh-support-csp-nonce.tar.gz
Add support for Content-Security-Policysh-support-csp-nonce
A nonce-based Content-Security-Policy thwarts XSS attacks by allowing inline JavaScript to execute if the script nonce matches the header value. Rails 5.2 supports nonce-based Content-Security-Policy headers, so provide configuration to enable this and make it work. To support this, we need to change all `:javascript` HAML filters to the following form: ``` = javascript_tag nonce: true do :plain ... ``` We use `%script` throughout our HAML to store JSON and other text, but since this doesn't execute, browsers don't appear to block this content from being used and require the nonce value to be present.
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js7
-rw-r--r--app/views/layouts/_google_analytics.html.haml20
-rw-r--r--app/views/layouts/_head.html.haml3
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml10
-rw-r--r--app/views/layouts/_init_client_detection_flags.html.haml8
-rw-r--r--app/views/layouts/_piwik.html.haml28
-rw-r--r--app/views/layouts/errors.html.haml16
-rw-r--r--app/views/layouts/group.html.haml6
-rw-r--r--app/views/layouts/project.html.haml6
-rw-r--r--app/views/layouts/snippets.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml12
-rw-r--r--changelogs/unreleased/sh-support-csp-nonce.yml5
-rw-r--r--config/gitlab.yml.example23
-rw-r--r--config/initializers/1_settings.rb1
-rw-r--r--config/initializers/content_security_policy.rb15
-rw-r--r--lib/gitlab/content_security_policy/config_loader.rb43
-rw-r--r--spec/lib/gitlab/content_security_policy/config_loader_spec.rb61
17 files changed, 212 insertions, 58 deletions
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 5e90893b684..31c4a920bbe 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -44,6 +44,11 @@ export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
+export const getCspNonceValue = () => {
+ const metaTag = document.querySelector('meta[name=csp-nonce]');
+ return metaTag && metaTag.content;
+};
+
export const ajaxGet = url =>
axios
.get(url, {
@@ -51,7 +56,7 @@ export const ajaxGet = url =>
responseType: 'text',
})
.then(({ data }) => {
- $.globalEval(data);
+ $.globalEval(data, { nonce: getCspNonceValue() });
});
export const rstrip = val => {
diff --git a/app/views/layouts/_google_analytics.html.haml b/app/views/layouts/_google_analytics.html.haml
index 98ea96b0b77..e8a5359e791 100644
--- a/app/views/layouts/_google_analytics.html.haml
+++ b/app/views/layouts/_google_analytics.html.haml
@@ -1,11 +1,11 @@
--# haml-lint:disable InlineJavaScript
-:javascript
- var _gaq = _gaq || [];
- _gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']);
- _gaq.push(['_trackPageview']);
+= javascript_tag nonce: true do
+ :plain
+ var _gaq = _gaq || [];
+ _gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']);
+ _gaq.push(['_trackPageview']);
- (function() {
- var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
- ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
- var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
- })();
+ (function() {
+ var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
+ ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
+ var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
+ })();
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index ac774803f95..271b73326fa 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -40,7 +40,7 @@
= stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all"
- = Gon::Base.render_data
+ = Gon::Base.render_data(nonce: content_security_policy_nonce)
- if content_for?(:library_javascripts)
= yield :library_javascripts
@@ -56,6 +56,7 @@
= yield :project_javascripts
= csrf_meta_tags
+ = csp_meta_tag
- unless browser.safari?
%meta{ name: 'referrer', content: 'origin-when-cross-origin' }
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 240e03a5d53..82ec92988eb 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -4,8 +4,8 @@
- datasources = autocomplete_data_sources(object, noteable_type)
- if object
- -# haml-lint:disable InlineJavaScript
- :javascript
- gl = window.gl || {};
- gl.GfmAutoComplete = gl.GfmAutoComplete || {};
- gl.GfmAutoComplete.dataSources = #{datasources.to_json};
+ = javascript_tag nonce: true do
+ :plain
+ gl = window.gl || {};
+ gl.GfmAutoComplete = gl.GfmAutoComplete || {};
+ gl.GfmAutoComplete.dataSources = #{datasources.to_json};
diff --git a/app/views/layouts/_init_client_detection_flags.html.haml b/app/views/layouts/_init_client_detection_flags.html.haml
index c729f8aa696..6537b86085f 100644
--- a/app/views/layouts/_init_client_detection_flags.html.haml
+++ b/app/views/layouts/_init_client_detection_flags.html.haml
@@ -1,7 +1,7 @@
- client = client_js_flags
- if client
- -# haml-lint:disable InlineJavaScript
- :javascript
- gl = window.gl || {};
- gl.client = #{client.to_json};
+ = javascript_tag nonce: true do
+ :plain
+ gl = window.gl || {};
+ gl.client = #{client.to_json};
diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml
index 473b14ce626..2cb2e23433d 100644
--- a/app/views/layouts/_piwik.html.haml
+++ b/app/views/layouts/_piwik.html.haml
@@ -1,15 +1,15 @@
<!-- Piwik -->
--# haml-lint:disable InlineJavaScript
-:javascript
- var _paq = _paq || [];
- _paq.push(['trackPageView']);
- _paq.push(['enableLinkTracking']);
- (function() {
- var u="//#{extra_config.piwik_url}/";
- _paq.push(['setTrackerUrl', u+'piwik.php']);
- _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]);
- var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
- g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
- })();
-<noscript><p><img src="//#{extra_config.piwik_url}/piwik.php?idsite=#{extra_config.piwik_site_id}" style="border:0;" alt="" /></p></noscript>
-<!-- End Piwik Code -->
+= javascript_tag nonce: true do
+ :plain
+ var _paq = _paq || [];
+ _paq.push(['trackPageView']);
+ _paq.push(['enableLinkTracking']);
+ (function() {
+ var u="//#{extra_config.piwik_url}/";
+ _paq.push(['setTrackerUrl', u+'piwik.php']);
+ _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]);
+ var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
+ g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
+ })();
+ <noscript><p><img src="//#{extra_config.piwik_url}/piwik.php?idsite=#{extra_config.piwik_site_id}" style="border:0;" alt="" /></p></noscript>
+ <!-- End Piwik Code -->
diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml
index 06069a72951..74484005b48 100644
--- a/app/views/layouts/errors.html.haml
+++ b/app/views/layouts/errors.html.haml
@@ -8,12 +8,12 @@
%body
.page-container
= yield
- -# haml-lint:disable InlineJavaScript
- :javascript
- (function(){
- var goBackElement = document.querySelector('.js-go-back');
+ = javascript_tag nonce: true do
+ :plain
+ (function(){
+ var goBackElement = document.querySelector('.js-go-back');
- if (goBackElement && history.length > 1) {
- goBackElement.style.display = 'block';
- }
- }());
+ if (goBackElement && history.length > 1) {
+ goBackElement.style.display = 'block';
+ }
+ }());
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 1d40b78fa83..49de821f1c2 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -6,8 +6,8 @@
- content_for :page_specific_javascripts do
- if current_user
- -# haml-lint:disable InlineJavaScript
- :javascript
- window.uploads_path = "#{group_uploads_path(@group)}";
+ = javascript_tag nonce: true do
+ :plain
+ window.uploads_path = "#{group_uploads_path(@group)}";
= render template: "layouts/application"
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 6b51483810e..b8ef38272fc 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -7,8 +7,8 @@
- content_for :project_javascripts do
- project = @target_project || @project
- if current_user
- -# haml-lint:disable InlineJavaScript
- :javascript
- window.uploads_path = "#{project_uploads_path(project)}";
+ = javascript_tag nonce: true do
+ :plain
+ window.uploads_path = "#{project_uploads_path(project)}";
= render template: "layouts/application"
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 841b2a5e79c..cde2b467392 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -3,8 +3,8 @@
- content_for :page_specific_javascripts do
- if snippets_upload_path
- -# haml-lint:disable InlineJavaScript
- :javascript
- window.uploads_path = "#{snippets_upload_path}";
+ = javascript_tag nonce: true do
+ :plain
+ window.uploads_path = "#{snippets_upload_path}";
= render template: "layouts/application"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 2c5c5141bf0..af3bd8dcd69 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -16,13 +16,13 @@
- if @merge_request.source_branch_exists?
= render "projects/merge_requests/how_to_merge"
- -# haml-lint:disable InlineJavaScript
- :javascript
- window.gl = window.gl || {};
- window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
+ = javascript_tag nonce: true do
+ :plain
+ window.gl = window.gl || {};
+ window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
- window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
- window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}';
+ window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
+ window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}';
#js-vue-mr-widget.mr-widget
diff --git a/changelogs/unreleased/sh-support-csp-nonce.yml b/changelogs/unreleased/sh-support-csp-nonce.yml
new file mode 100644
index 00000000000..3e6ac1e4a32
--- /dev/null
+++ b/changelogs/unreleased/sh-support-csp-nonce.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for Content-Security-Policy
+merge_request: 31402
+author:
+type: added
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 39b719a5978..226f2ec3722 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -47,6 +47,29 @@ production: &base
#
# relative_url_root: /gitlab
+ # Content Security Policy
+ # See https://guides.rubyonrails.org/security.html#content-security-policy
+ content_security_policy:
+ enabled: false
+ report_only: false
+ directives:
+ base_uri:
+ child_src:
+ connect_src: "'self' http://localhost:3808 ws://localhost:3808 wss://localhost:3000"
+ default_src: "'self'"
+ font_src:
+ form_action:
+ frame_ancestors: "'self'"
+ frame_src: "'self' https://www.google.com/recaptcha/ https://www.recaptcha.net/ https://content.googleapis.com https://content-compute.googleapis.com https://content-cloudbilling.googleapis.com https://content-cloudresourcemanager.googleapis.com"
+ img_src: "* data: blob"
+ manifest_src:
+ media_src:
+ object_src: "'self' http://localhost:3808 'unsafe-inline' 'unsafe-eval' https://www.google.com/recaptcha/ https://www.recaptcha.net/ https://www.gstatic.com/recaptcha/ https://apis.google.com"
+ script_src:
+ style_src: "'self' 'unsafe-inline'"
+ worker_src: "http://localhost:3000 blob:"
+ report_uri:
+
# Trusted Proxies
# Customize if you have GitLab behind a reverse proxy which is running on a different machine.
# Add the IP address for your reverse proxy to the list, otherwise users will appear signed in from that address.
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 659801f787d..828732126b6 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -200,6 +200,7 @@ Settings.gitlab.default_projects_features['visibility_level'] = Settings.__sen
Settings.gitlab['domain_whitelist'] ||= []
Settings.gitlab['import_sources'] ||= Gitlab::ImportSources.values
Settings.gitlab['trusted_proxies'] ||= []
+Settings.gitlab['content_security_policy'] ||= Gitlab::ContentSecurityPolicy::ConfigLoader.default_settings_hash
Settings.gitlab['no_todos_messages'] ||= YAML.load_file(Rails.root.join('config', 'no_todos_messages.yml'))
Settings.gitlab['impersonation_enabled'] ||= true if Settings.gitlab['impersonation_enabled'].nil?
Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil?
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
new file mode 100644
index 00000000000..608d0401a96
--- /dev/null
+++ b/config/initializers/content_security_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+csp_settings = Settings.gitlab.content_security_policy
+
+if csp_settings['enabled']
+ # See https://guides.rubyonrails.org/security.html#content-security-policy
+ Rails.application.config.content_security_policy do |policy|
+ directives = csp_settings.fetch('directives', {})
+ loader = ::Gitlab::ContentSecurityPolicy::ConfigLoader.new(directives)
+ loader.load(policy)
+ end
+
+ Rails.application.config.content_security_policy_report_only = csp_settings['report_only']
+ Rails.application.config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
+end
diff --git a/lib/gitlab/content_security_policy/config_loader.rb b/lib/gitlab/content_security_policy/config_loader.rb
new file mode 100644
index 00000000000..b2f3345d33a
--- /dev/null
+++ b/lib/gitlab/content_security_policy/config_loader.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module ContentSecurityPolicy
+ class ConfigLoader
+ DIRECTIVES = %w(base_uri child_src connect_src default_src font_src
+ form_action frame_ancestors frame_src img_src manifest_src
+ media_src object_src script_src style_src worker_src).freeze
+
+ def self.default_settings_hash
+ {
+ 'enabled' => false,
+ 'report_only' => false,
+ 'directives' => DIRECTIVES.each_with_object({}) { |directive, hash| hash[directive] = nil }
+ }
+ end
+
+ def initialize(csp_directives)
+ @csp_directives = HashWithIndifferentAccess.new(csp_directives)
+ end
+
+ def load(policy)
+ DIRECTIVES.each do |directive|
+ arguments = arguments_for(directive)
+
+ next unless arguments.present?
+
+ policy.public_send(directive, *arguments) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ private
+
+ def arguments_for(directive)
+ arguments = @csp_directives[directive.to_s]
+
+ return unless arguments.present? && arguments.is_a?(String)
+
+ arguments.strip.split(' ').map(&:strip)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
new file mode 100644
index 00000000000..e6c8db2e99a
--- /dev/null
+++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ContentSecurityPolicy::ConfigLoader do
+ include StubConfiguration
+
+ let(:policy) { ActionDispatch::ContentSecurityPolicy.new }
+ let(:csp_config) do
+ {
+ enabled: true,
+ report_only: false,
+ directives: {
+ base_uri: 'http://example.com',
+ child_src: "'self' https://child.example.com",
+ default_src: "'self' https://other.example.com",
+ script_src: "'self' https://script.exammple.com ",
+ worker_src: "data: https://worker.example.com"
+ }
+ }
+ end
+
+ context '.default_settings_hash' do
+ it 'returns empty defaults' do
+ settings = described_class.default_settings_hash
+
+ expect(settings['enabled']).to be_falsey
+ expect(settings['report_only']).to be_falsey
+
+ described_class::DIRECTIVES.each do |directive|
+ expect(settings['directives'].has_key?(directive)).to be_truthy
+ expect(settings['directives'][directive]).to be_nil
+ end
+ end
+ end
+
+ context '#load' do
+ subject { described_class.new(csp_config[:directives]) }
+
+ def expected_config(directive)
+ csp_config[:directives][directive].split(' ').map(&:strip)
+ end
+
+ it 'sets the policy properly' do
+ subject.load(policy)
+
+ expect(policy.directives['base-uri']).to eq([csp_config[:directives][:base_uri]])
+ expect(policy.directives['default-src']).to eq(expected_config(:default_src))
+ expect(policy.directives['child-src']).to eq(expected_config(:child_src))
+ expect(policy.directives['worker-src']).to eq(expected_config(:worker_src))
+ end
+
+ it 'ignores malformed policy statements' do
+ csp_config[:directives][:base_uri] = 123
+
+ subject.load(policy)
+
+ expect(policy.directives['base-uri']).to be_nil
+ end
+ end
+end