FEATURE: [Experimental] Content Security Policy (#6504)

This commit is contained in:
Kyle Zhao 2018-10-19 10:39:22 -04:00 committed by GitHub
parent 3d5085c045
commit fb8231077a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 327 additions and 3 deletions

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class CspReportsController < ApplicationController
skip_before_action :check_xhr, :preload_json, :verify_authenticity_token, only: [:create]
def create
raise Discourse::NotFound unless report_collection_enabled?
Logster.add_to_env(request.env, 'CSP Report', report)
Rails.logger.warn("CSP Violation: '#{report['blocked-uri']}'")
head :ok
end
private
def report
@report ||= params.require('csp-report').permit(
'blocked-uri',
'disposition',
'document-uri',
'effective-directive',
'original-policy',
'referrer',
'script-sample',
'status-code',
'violated-directive',
'line-number',
'source-file'
).to_h
end
def report_collection_enabled?
ContentSecurityPolicy.enabled? && SiteSetting.content_security_policy_collect_reports
end
end

View File

@ -190,6 +190,9 @@ module Discourse
# supports etags (post 1.7)
config.middleware.delete Rack::ETag
require 'content_security_policy'
config.middleware.swap ActionDispatch::ContentSecurityPolicy::Middleware, ContentSecurityPolicy::Middleware
require 'middleware/discourse_public_exceptions'
config.exceptions_app = Middleware::DiscoursePublicExceptions.new(Rails.public_path)

View File

@ -0,0 +1 @@
Mime::Type.register 'application/csp-report', :json

View File

@ -1267,6 +1267,10 @@ en:
blacklisted_crawler_user_agents: 'Unique case insensitive word in the user agent string identifying web crawlers that should not be allowed to access the site. Does not apply if whitelist is defined.'
slow_down_crawler_user_agents: 'User agents of web crawlers that should be rate limited in robots.txt using the Crawl-delay directive'
slow_down_crawler_rate: 'If slow_down_crawler_user_agents is specified this rate will apply to all the crawlers (number of seconds delay between requests)'
content_security_policy: EXPERIMENTAL - Turn on Content-Security-Policy
content_security_policy_report_only: EXPERIMENTAL - Turn on Content-Security-Policy-Report-Only
content_security_policy_collect_reports: Enable CSP violation report collection at /csp_reports
content_security_policy_script_src: Additional whitelisted script sources. The current host and CDN are included by default.
top_menu: "Determine which items appear in the homepage navigation, and in what order. Example latest|new|unread|categories|top|read|posted|bookmarks"
post_menu: "Determine which items appear on the post menu, and in what order. Example like|edit|flag|delete|share|bookmark|reply"
post_menu_hidden_items: "The menu items to hide by default in the post menu unless an expansion ellipsis is clicked on."
@ -1868,7 +1872,7 @@ en:
sso_provider_secrets:
key: "www.example.com"
value: "SSO secret"
search:
within_post: "#%{post_number} by %{username}"
types:

View File

@ -828,6 +828,8 @@ Discourse::Application.routes.draw do
post "/push_notifications/subscribe" => "push_notification#subscribe"
post "/push_notifications/unsubscribe" => "push_notification#unsubscribe"
resources :csp_reports, only: [:create]
get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new
end

View File

@ -1179,6 +1179,15 @@ security:
default: 'bingbot'
list_type: compact
slow_down_crawler_rate: 60
content_security_policy:
default: false
content_security_policy_report_only:
default: false
content_security_policy_collect_reports:
default: true
content_security_policy_script_src:
type: list
default: ''
onebox:
enable_flash_video_onebox: false
@ -1801,4 +1810,4 @@ tags:
remove_muted_tags_from_latest:
default: false
force_lowercase_tags:
default: true
default: true

View File

@ -0,0 +1,83 @@
# frozen_string_literal: true
require_dependency 'global_path'
class ContentSecurityPolicy
include GlobalPath
class Middleware
WHITELISTED_PATHS = %w(
/logs
)
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
_, headers, _ = response = @app.call(env)
return response unless html_response?(headers) && ContentSecurityPolicy.enabled?
return response if whitelisted?(request.path)
policy = ContentSecurityPolicy.new.build
headers['Content-Security-Policy'] = policy if SiteSetting.content_security_policy
headers['Content-Security-Policy-Report-Only'] = policy if SiteSetting.content_security_policy_report_only
response
end
private
def html_response?(headers)
headers['Content-Type'] && headers['Content-Type'] =~ /html/
end
def whitelisted?(path)
if GlobalSetting.relative_url_root
path.slice!(/^#{Regexp.quote(GlobalSetting.relative_url_root)}/)
end
WHITELISTED_PATHS.any? { |whitelisted| path.start_with?(whitelisted) }
end
end
def self.enabled?
SiteSetting.content_security_policy || SiteSetting.content_security_policy_report_only
end
def initialize
@directives = {
script_src: script_src,
}
@directives[:report_uri] = path('/csp_reports') if SiteSetting.content_security_policy_collect_reports
end
def build
policy = ActionDispatch::ContentSecurityPolicy.new
@directives.each do |directive, sources|
if sources.is_a?(Array)
policy.public_send(directive, *sources)
else
policy.public_send(directive, sources)
end
end
policy.build
end
private
def script_src
sources = [:self, :unsafe_eval]
sources << :https if SiteSetting.force_https
sources << Discourse.asset_host if Discourse.asset_host.present?
sources << 'www.google-analytics.com' if SiteSetting.ga_universal_tracking_code.present?
sources << 'www.googletagmanager.com' if SiteSetting.gtm_container_id.present?
sources.concat(SiteSetting.content_security_policy_script_src.split('|'))
end
end

View File

@ -0,0 +1,61 @@
require 'rails_helper'
describe ContentSecurityPolicy do
describe 'report-uri' do
it 'is enabled by SiteSetting' do
SiteSetting.content_security_policy_collect_reports = true
report_uri = parse(ContentSecurityPolicy.new.build)['report-uri'].first
expect(report_uri).to eq('/csp_reports')
SiteSetting.content_security_policy_collect_reports = false
report_uri = parse(ContentSecurityPolicy.new.build)['report-uri']
expect(report_uri).to eq(nil)
end
end
describe 'script-src defaults' do
it 'always have self and unsafe-eval' do
script_srcs = parse(ContentSecurityPolicy.new.build)['script-src']
expect(script_srcs).to eq(%w['self' 'unsafe-eval'])
end
it 'enforces https when SiteSetting.force_https' do
SiteSetting.force_https = true
script_srcs = parse(ContentSecurityPolicy.new.build)['script-src']
expect(script_srcs).to include('https:')
end
it 'whitelists Google Analytics and Tag Manager when integrated' do
SiteSetting.ga_universal_tracking_code = 'UA-12345678-9'
SiteSetting.gtm_container_id = 'GTM-ABCDEF'
script_srcs = parse(ContentSecurityPolicy.new.build)['script-src']
expect(script_srcs).to include('www.google-analytics.com')
expect(script_srcs).to include('www.googletagmanager.com')
end
it 'whitelists CDN when integrated' do
set_cdn_url('cdn.com')
script_srcs = parse(ContentSecurityPolicy.new.build)['script-src']
expect(script_srcs).to include('cdn.com')
end
it 'can be extended with more sources' do
SiteSetting.content_security_policy_script_src = 'example.com|another.com'
script_srcs = parse(ContentSecurityPolicy.new.build)['script-src']
expect(script_srcs).to include('example.com')
expect(script_srcs).to include('another.com')
expect(script_srcs).to include("'unsafe-eval'")
expect(script_srcs).to include("'self'")
end
end
def parse(csp_string)
csp_string.split(';').map do |policy|
directive, *sources = policy.split
[directive, sources]
end.to_h
end
end

View File

@ -196,4 +196,69 @@ RSpec.describe ApplicationController do
expect(controller.theme_ids).to eq([theme.id])
end
end
describe 'Content Security Policy' do
it 'is enabled by SiteSettings' do
SiteSetting.content_security_policy = false
SiteSetting.content_security_policy_report_only = false
get '/'
expect(response.headers).to_not include('Content-Security-Policy')
expect(response.headers).to_not include('Content-Security-Policy-Report-Only')
SiteSetting.content_security_policy = true
SiteSetting.content_security_policy_report_only = true
get '/'
expect(response.headers).to include('Content-Security-Policy')
expect(response.headers).to include('Content-Security-Policy-Report-Only')
end
it 'can be customized with SiteSetting' do
SiteSetting.content_security_policy = true
get '/'
script_src = parse(response.headers['Content-Security-Policy'])['script-src']
expect(script_src).to_not include('example.com')
SiteSetting.content_security_policy_script_src = 'example.com'
get '/'
script_src = parse(response.headers['Content-Security-Policy'])['script-src']
expect(script_src).to include('example.com')
expect(script_src).to include("'self'")
expect(script_src).to include("'unsafe-eval'")
end
it 'does not set CSP when responding to non-HTML' do
SiteSetting.content_security_policy = true
SiteSetting.content_security_policy_report_only = true
get '/latest.json'
expect(response.headers).to_not include('Content-Security-Policy')
expect(response.headers).to_not include('Content-Security-Policy-Report-Only')
end
it 'does not set CSP for /logs' do
sign_in(Fabricate(:admin))
SiteSetting.content_security_policy = true
get '/logs'
expect(response.status).to eq(200)
expect(response.headers).to_not include('Content-Security-Policy')
end
def parse(csp_string)
csp_string.split(';').map do |policy|
directive, *sources = policy.split
[directive, sources]
end.to_h
end
end
end

View File

@ -0,0 +1,56 @@
require 'rails_helper'
describe CspReportsController do
describe '#create' do
before do
SiteSetting.content_security_policy = true
SiteSetting.content_security_policy_collect_reports = true
@orig_logger = Rails.logger
Rails.logger = @fake_logger = FakeLogger.new
end
after do
Rails.logger = @orig_logger
end
def send_report
post '/csp_reports', params: {
"csp-report": {
"document-uri": "http://localhost:3000/",
"referrer": "",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "script-src 'unsafe-eval' www.google-analytics.com; report-uri /csp_reports",
"disposition": "report",
"blocked-uri": "http://suspicio.us/assets.js",
"line-number": 25,
"source-file": "http://localhost:3000/",
"status-code": 200,
"script-sample": ""
}, headers: { "Content-Type": "application/csp-report" }
}
end
it 'is enabled by SiteSetting' do
SiteSetting.content_security_policy = false
SiteSetting.content_security_policy_report_only = false
SiteSetting.content_security_policy_collect_reports = true
send_report
expect(response.status).to eq(404)
SiteSetting.content_security_policy = true
send_report
expect(response.status).to eq(200)
SiteSetting.content_security_policy_collect_reports = false
send_report
expect(response.status).to eq(404)
end
it 'logs the violation report' do
send_report
expect(Rails.logger.warnings).to include("CSP Violation: 'http://suspicio.us/assets.js'")
end
end
end

View File

@ -1,9 +1,14 @@
class FakeLogger
attr_reader :warnings, :errors
attr_reader :warnings, :errors, :infos
def initialize
@warnings = []
@errors = []
@infos = []
end
def info(message = nil)
@infos << message
end
def warn(message)