Add support for Content-Security-Policy
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.
This commit is contained in:
parent
fa216b0e86
commit
5fbbd3dd6e
|
@ -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 => {
|
||||
|
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}());
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add support for Content-Security-Policy
|
||||
merge_request: 31402
|
||||
author:
|
||||
type: added
|
|
@ -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.
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::ContentSecurityPolicy::ConfigLoader do
|
||||
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
|
Loading…
Reference in New Issue