Commit 5fbbd3dd authored by Stan Hu's avatar Stan Hu Committed by Ash McKenzie

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.
parent fa216b0e
...@@ -44,6 +44,11 @@ export const isInIssuePage = () => checkPageAndAction('issues', 'show'); ...@@ -44,6 +44,11 @@ export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', '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 => export const ajaxGet = url =>
axios axios
.get(url, { .get(url, {
...@@ -51,7 +56,7 @@ export const ajaxGet = url => ...@@ -51,7 +56,7 @@ export const ajaxGet = url =>
responseType: 'text', responseType: 'text',
}) })
.then(({ data }) => { .then(({ data }) => {
$.globalEval(data); $.globalEval(data, { nonce: getCspNonceValue() });
}); });
export const rstrip = val => { export const rstrip = val => {
......
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
var _gaq = _gaq || []; var _gaq = _gaq || [];
_gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']); _gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']);
_gaq.push(['_trackPageview']); _gaq.push(['_trackPageview']);
(function() { (function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; 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'; 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); var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})(); })();
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
= stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all" = 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) - if content_for?(:library_javascripts)
= yield :library_javascripts = yield :library_javascripts
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
= yield :project_javascripts = yield :project_javascripts
= csrf_meta_tags = csrf_meta_tags
= csp_meta_tag
- unless browser.safari? - unless browser.safari?
%meta{ name: 'referrer', content: 'origin-when-cross-origin' } %meta{ name: 'referrer', content: 'origin-when-cross-origin' }
......
...@@ -4,8 +4,8 @@ ...@@ -4,8 +4,8 @@
- datasources = autocomplete_data_sources(object, noteable_type) - datasources = autocomplete_data_sources(object, noteable_type)
- if object - if object
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
gl = window.gl || {}; gl = window.gl || {};
gl.GfmAutoComplete = gl.GfmAutoComplete || {}; gl.GfmAutoComplete = gl.GfmAutoComplete || {};
gl.GfmAutoComplete.dataSources = #{datasources.to_json}; gl.GfmAutoComplete.dataSources = #{datasources.to_json};
- client = client_js_flags - client = client_js_flags
- if client - if client
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
gl = window.gl || {}; gl = window.gl || {};
gl.client = #{client.to_json}; gl.client = #{client.to_json};
<!-- Piwik --> <!-- Piwik -->
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
var _paq = _paq || []; var _paq = _paq || [];
_paq.push(['trackPageView']); _paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']); _paq.push(['enableLinkTracking']);
(function() { (function() {
var u="//#{extra_config.piwik_url}/"; var u="//#{extra_config.piwik_url}/";
_paq.push(['setTrackerUrl', u+'piwik.php']); _paq.push(['setTrackerUrl', u+'piwik.php']);
_paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]); _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; 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); 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> <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 --> <!-- End Piwik Code -->
...@@ -8,12 +8,12 @@ ...@@ -8,12 +8,12 @@
%body %body
.page-container .page-container
= yield = yield
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
(function(){ (function(){
var goBackElement = document.querySelector('.js-go-back'); var goBackElement = document.querySelector('.js-go-back');
if (goBackElement && history.length > 1) { if (goBackElement && history.length > 1) {
goBackElement.style.display = 'block'; goBackElement.style.display = 'block';
} }
}()); }());
...@@ -6,8 +6,8 @@ ...@@ -6,8 +6,8 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
- if current_user - if current_user
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
window.uploads_path = "#{group_uploads_path(@group)}"; window.uploads_path = "#{group_uploads_path(@group)}";
= render template: "layouts/application" = render template: "layouts/application"
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
- content_for :project_javascripts do - content_for :project_javascripts do
- project = @target_project || @project - project = @target_project || @project
- if current_user - if current_user
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
window.uploads_path = "#{project_uploads_path(project)}"; window.uploads_path = "#{project_uploads_path(project)}";
= render template: "layouts/application" = render template: "layouts/application"
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
- if snippets_upload_path - if snippets_upload_path
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
window.uploads_path = "#{snippets_upload_path}"; window.uploads_path = "#{snippets_upload_path}";
= render template: "layouts/application" = render template: "layouts/application"
...@@ -16,13 +16,13 @@ ...@@ -16,13 +16,13 @@
- if @merge_request.source_branch_exists? - if @merge_request.source_branch_exists?
= render "projects/merge_requests/how_to_merge" = render "projects/merge_requests/how_to_merge"
-# haml-lint:disable InlineJavaScript = javascript_tag nonce: true do
:javascript :plain
window.gl = window.gl || {}; window.gl = window.gl || {};
window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} 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.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.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}';
#js-vue-mr-widget.mr-widget #js-vue-mr-widget.mr-widget
......
---
title: Add support for Content-Security-Policy
merge_request: 31402
author:
type: added
...@@ -47,6 +47,29 @@ production: &base ...@@ -47,6 +47,29 @@ production: &base
# #
# relative_url_root: /gitlab # 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 # Trusted Proxies
# Customize if you have GitLab behind a reverse proxy which is running on a different machine. # 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. # 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 ...@@ -200,6 +200,7 @@ Settings.gitlab.default_projects_features['visibility_level'] = Settings.__sen
Settings.gitlab['domain_whitelist'] ||= [] Settings.gitlab['domain_whitelist'] ||= []
Settings.gitlab['import_sources'] ||= Gitlab::ImportSources.values Settings.gitlab['import_sources'] ||= Gitlab::ImportSources.values
Settings.gitlab['trusted_proxies'] ||= [] 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['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['impersonation_enabled'] ||= true if Settings.gitlab['impersonation_enabled'].nil?
Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil? Settings.gitlab['usage_ping_enabled'] = true if Settings.gitlab['usage_ping_enabled'].nil?
......
# 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
# 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
# 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
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