mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 17:35:12 +08:00
174a8b431b
Raw paths like `/test/path` are not supported natively in the CSP. This commit prepends the site's base URL to these paths. This allows plugins to add 'local' assets to the CSP without needing to hardcode the site's hostname.
370 lines
13 KiB
Ruby
370 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
RSpec.describe ContentSecurityPolicy do
|
|
after do
|
|
DiscoursePluginRegistry.reset!
|
|
end
|
|
|
|
describe 'report-uri' do
|
|
it 'is enabled by SiteSetting' do
|
|
SiteSetting.content_security_policy_collect_reports = true
|
|
report_uri = parse(policy)['report-uri'].first
|
|
expect(report_uri).to eq('http://test.localhost/csp_reports')
|
|
|
|
SiteSetting.content_security_policy_collect_reports = false
|
|
report_uri = parse(policy)['report-uri']
|
|
expect(report_uri).to eq(nil)
|
|
end
|
|
end
|
|
|
|
describe 'base-uri' do
|
|
it 'is set to self' do
|
|
base_uri = parse(policy)['base-uri']
|
|
expect(base_uri).to eq(["'self'"])
|
|
end
|
|
end
|
|
|
|
describe 'object-src' do
|
|
it 'is set to none' do
|
|
object_srcs = parse(policy)['object-src']
|
|
expect(object_srcs).to eq(["'none'"])
|
|
end
|
|
end
|
|
|
|
describe 'upgrade-insecure-requests' do
|
|
it 'is not included when force_https is off' do
|
|
SiteSetting.force_https = false
|
|
expect(parse(policy)['upgrade-insecure-requests']).to eq(nil)
|
|
end
|
|
|
|
it 'is included when force_https is on' do
|
|
SiteSetting.force_https = true
|
|
expect(parse(policy)['upgrade-insecure-requests']).to eq([])
|
|
end
|
|
end
|
|
|
|
describe 'worker-src' do
|
|
it 'has expected values' do
|
|
worker_srcs = parse(policy)['worker-src']
|
|
expect(worker_srcs).to eq(%w[
|
|
'self'
|
|
http://test.localhost/assets/
|
|
http://test.localhost/brotli_asset/
|
|
http://test.localhost/javascripts/
|
|
http://test.localhost/plugins/
|
|
])
|
|
end
|
|
end
|
|
|
|
describe 'script-src' do
|
|
it 'always has self, logster, sidekiq, and assets' do
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include(*%w[
|
|
http://test.localhost/logs/
|
|
http://test.localhost/sidekiq/
|
|
http://test.localhost/mini-profiler-resources/
|
|
http://test.localhost/assets/
|
|
http://test.localhost/brotli_asset/
|
|
http://test.localhost/extra-locales/
|
|
http://test.localhost/highlight-js/
|
|
http://test.localhost/javascripts/
|
|
http://test.localhost/plugins/
|
|
http://test.localhost/theme-javascripts/
|
|
http://test.localhost/svg-sprite/
|
|
])
|
|
end
|
|
|
|
it 'includes "report-sample" when report collection is enabled' do
|
|
SiteSetting.content_security_policy_collect_reports = true
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include("'report-sample'")
|
|
end
|
|
|
|
context 'for Google Analytics' do
|
|
before do
|
|
SiteSetting.ga_universal_tracking_code = 'UA-12345678-9'
|
|
end
|
|
|
|
it 'allowlists Google Analytics v3 when integrated' do
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include('https://www.google-analytics.com/analytics.js')
|
|
expect(script_srcs).not_to include('https://www.googletagmanager.com/gtag/js')
|
|
end
|
|
|
|
it 'allowlists Google Analytics v4 when integrated' do
|
|
SiteSetting.ga_version = 'v4_gtag'
|
|
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include('https://www.google-analytics.com/analytics.js')
|
|
expect(script_srcs).to include('https://www.googletagmanager.com/gtag/js')
|
|
end
|
|
end
|
|
|
|
it 'allowlists Google Tag Manager when integrated' do
|
|
SiteSetting.gtm_container_id = 'GTM-ABCDEF'
|
|
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include('https://www.googletagmanager.com/gtm.js')
|
|
expect(script_srcs.to_s).to include('nonce-')
|
|
end
|
|
|
|
it 'allowlists CDN assets when integrated' do
|
|
set_cdn_url('https://cdn.com')
|
|
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include(*%w[
|
|
https://cdn.com/assets/
|
|
https://cdn.com/brotli_asset/
|
|
https://cdn.com/highlight-js/
|
|
https://cdn.com/javascripts/
|
|
https://cdn.com/plugins/
|
|
https://cdn.com/theme-javascripts/
|
|
http://test.localhost/extra-locales/
|
|
])
|
|
|
|
global_setting(:s3_cdn_url, 'https://s3-cdn.com')
|
|
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include(*%w[
|
|
https://s3-cdn.com/assets/
|
|
https://s3-cdn.com/brotli_asset/
|
|
https://cdn.com/highlight-js/
|
|
https://cdn.com/javascripts/
|
|
https://cdn.com/plugins/
|
|
https://cdn.com/theme-javascripts/
|
|
http://test.localhost/extra-locales/
|
|
])
|
|
end
|
|
|
|
it 'adds subfolder to CDN assets' do
|
|
set_cdn_url('https://cdn.com')
|
|
set_subfolder('/forum')
|
|
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include(*%w[
|
|
https://cdn.com/forum/assets/
|
|
https://cdn.com/forum/brotli_asset/
|
|
https://cdn.com/forum/highlight-js/
|
|
https://cdn.com/forum/javascripts/
|
|
https://cdn.com/forum/plugins/
|
|
https://cdn.com/forum/theme-javascripts/
|
|
http://test.localhost/forum/extra-locales/
|
|
])
|
|
|
|
global_setting(:s3_cdn_url, 'https://s3-cdn.com')
|
|
|
|
script_srcs = parse(policy)['script-src']
|
|
expect(script_srcs).to include(*%w[
|
|
https://s3-cdn.com/assets/
|
|
https://s3-cdn.com/brotli_asset/
|
|
https://cdn.com/forum/highlight-js/
|
|
https://cdn.com/forum/javascripts/
|
|
https://cdn.com/forum/plugins/
|
|
https://cdn.com/forum/theme-javascripts/
|
|
http://test.localhost/forum/extra-locales/
|
|
])
|
|
end
|
|
end
|
|
|
|
describe 'manifest-src' do
|
|
it 'is set to self' do
|
|
expect(parse(policy)['manifest-src']).to eq(["'self'"])
|
|
end
|
|
end
|
|
|
|
describe 'frame-ancestors' do
|
|
context 'with content_security_policy_frame_ancestors enabled' do
|
|
before do
|
|
SiteSetting.content_security_policy_frame_ancestors = true
|
|
Fabricate(:embeddable_host, host: 'https://a.org')
|
|
Fabricate(:embeddable_host, host: 'https://b.org')
|
|
end
|
|
|
|
it 'always has self' do
|
|
frame_ancestors = parse(policy)['frame-ancestors']
|
|
expect(frame_ancestors).to include("'self'")
|
|
end
|
|
|
|
it 'includes all EmbeddableHost' do
|
|
EmbeddableHost
|
|
frame_ancestors = parse(policy)['frame-ancestors']
|
|
expect(frame_ancestors).to include("https://a.org")
|
|
expect(frame_ancestors).to include("https://b.org")
|
|
end
|
|
end
|
|
|
|
context 'with content_security_policy_frame_ancestors disabled' do
|
|
before do
|
|
SiteSetting.content_security_policy_frame_ancestors = false
|
|
end
|
|
|
|
it 'does not set frame-ancestors' do
|
|
frame_ancestors = parse(policy)['frame-ancestors']
|
|
expect(frame_ancestors).to be_nil
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with a plugin' do
|
|
let(:plugin_class) do
|
|
Class.new(Plugin::Instance) do
|
|
attr_accessor :enabled
|
|
def enabled?
|
|
@enabled
|
|
end
|
|
end
|
|
end
|
|
|
|
it 'can extend script-src, object-src, manifest-src' do
|
|
plugin = plugin_class.new(nil, "#{Rails.root}/spec/fixtures/plugins/csp_extension/plugin.rb")
|
|
|
|
plugin.activate!
|
|
Discourse.plugins << plugin
|
|
|
|
plugin.enabled = true
|
|
expect(parse(policy)['script-src']).to include('https://from-plugin.com')
|
|
expect(parse(policy)['script-src']).to include('http://test.localhost/local/path')
|
|
expect(parse(policy)['object-src']).to include('https://test-stripping.com')
|
|
expect(parse(policy)['object-src']).to_not include("'none'")
|
|
expect(parse(policy)['manifest-src']).to include("'self'")
|
|
expect(parse(policy)['manifest-src']).to include('https://manifest-src.com')
|
|
|
|
plugin.enabled = false
|
|
expect(parse(policy)['script-src']).to_not include('https://from-plugin.com')
|
|
expect(parse(policy)['manifest-src']).to_not include('https://manifest-src.com')
|
|
|
|
Discourse.plugins.delete plugin
|
|
DiscoursePluginRegistry.reset!
|
|
end
|
|
|
|
it 'can extend frame_ancestors' do
|
|
SiteSetting.content_security_policy_frame_ancestors = true
|
|
plugin = plugin_class.new(nil, "#{Rails.root}/spec/fixtures/plugins/csp_extension/plugin.rb")
|
|
|
|
plugin.activate!
|
|
Discourse.plugins << plugin
|
|
|
|
plugin.enabled = true
|
|
expect(parse(policy)['frame-ancestors']).to include("'self'")
|
|
expect(parse(policy)['frame-ancestors']).to include('https://frame-ancestors-plugin.ext')
|
|
|
|
plugin.enabled = false
|
|
expect(parse(policy)['frame-ancestors']).to_not include('https://frame-ancestors-plugin.ext')
|
|
|
|
Discourse.plugins.delete plugin
|
|
DiscoursePluginRegistry.reset!
|
|
end
|
|
end
|
|
|
|
it 'only includes unsafe-inline for qunit paths' do
|
|
expect(parse(policy(path_info: "/qunit"))['script-src']).to include("'unsafe-eval'")
|
|
expect(parse(policy(path_info: "/wizard/qunit"))['script-src']).to include("'unsafe-eval'")
|
|
expect(parse(policy(path_info: "/"))['script-src']).to_not include("'unsafe-eval'")
|
|
end
|
|
|
|
context "with a theme" do
|
|
let!(:theme) {
|
|
Fabricate(:theme).tap do |t|
|
|
settings = <<~YML
|
|
extend_content_security_policy:
|
|
type: list
|
|
default: 'script-src: from-theme.com'
|
|
YML
|
|
t.set_field(target: :settings, name: :yaml, value: settings)
|
|
t.save!
|
|
end
|
|
}
|
|
|
|
def theme_policy
|
|
policy(theme.id)
|
|
end
|
|
|
|
it 'can be extended by themes' do
|
|
policy # call this first to make sure further actions clear the cache
|
|
|
|
expect(parse(policy)['script-src']).not_to include('from-theme.com')
|
|
|
|
expect(parse(theme_policy)['script-src']).to include('from-theme.com')
|
|
|
|
theme.update_setting(:extend_content_security_policy, "script-src: https://from-theme.net|worker-src: from-theme.com")
|
|
theme.save!
|
|
|
|
expect(parse(theme_policy)['script-src']).to_not include('from-theme.com')
|
|
expect(parse(theme_policy)['script-src']).to include('https://from-theme.net')
|
|
expect(parse(theme_policy)['worker-src']).to include('from-theme.com')
|
|
|
|
theme.destroy!
|
|
|
|
expect(parse(theme_policy)['script-src']).to_not include('https://from-theme.net')
|
|
expect(parse(theme_policy)['worker-src']).to_not include('from-theme.com')
|
|
end
|
|
|
|
it 'can be extended by theme modifiers' do
|
|
policy # call this first to make sure further actions clear the cache
|
|
|
|
theme.theme_modifier_set.csp_extensions = ["script-src: https://from-theme-flag.script", "worker-src: from-theme-flag.worker"]
|
|
theme.save!
|
|
|
|
child_theme = Fabricate(:theme, component: true)
|
|
theme.add_relative_theme!(:child, child_theme)
|
|
child_theme.theme_modifier_set.csp_extensions = ["script-src: https://child-theme-flag.script", "worker-src: child-theme-flag.worker"]
|
|
child_theme.save!
|
|
|
|
expect(parse(theme_policy)['script-src']).to include('https://from-theme-flag.script')
|
|
expect(parse(theme_policy)['script-src']).to include('https://child-theme-flag.script')
|
|
expect(parse(theme_policy)['worker-src']).to include('from-theme-flag.worker')
|
|
expect(parse(theme_policy)['worker-src']).to include('child-theme-flag.worker')
|
|
|
|
theme.destroy!
|
|
child_theme.destroy!
|
|
|
|
expect(parse(theme_policy)['script-src']).to_not include('https://from-theme-flag.script')
|
|
expect(parse(theme_policy)['worker-src']).to_not include('from-theme-flag.worker')
|
|
expect(parse(theme_policy)['worker-src']).to_not include('from-theme-flag.worker')
|
|
expect(parse(theme_policy)['worker-src']).to_not include('child-theme-flag.worker')
|
|
end
|
|
|
|
it 'is extended automatically when themes reference external scripts' do
|
|
policy # call this first to make sure further actions clear the cache
|
|
|
|
theme.set_field(target: :common, name: "header", value: <<~HTML)
|
|
<script src='https://example.com/myscript.js'></script>
|
|
<script src='https://example.com/myscript2.js?with=query'></script>
|
|
<script src='//example2.com/protocol-less-script.js'></script>
|
|
<script src='domain-only.com'></script>
|
|
<script>console.log('inline script')</script>
|
|
HTML
|
|
|
|
theme.set_field(target: :desktop, name: "header", value: "")
|
|
theme.save!
|
|
|
|
expect(parse(theme_policy)['script-src']).to include('https://example.com/myscript.js')
|
|
expect(parse(theme_policy)['script-src']).to include('https://example.com/myscript2.js')
|
|
expect(parse(theme_policy)['script-src']).not_to include('?')
|
|
expect(parse(theme_policy)['script-src']).to include('example2.com/protocol-less-script.js')
|
|
expect(parse(theme_policy)['script-src']).not_to include('domain-only.com')
|
|
expect(parse(theme_policy)['script-src']).not_to include(a_string_matching /^\/theme-javascripts/)
|
|
|
|
theme.destroy!
|
|
|
|
expect(parse(theme_policy)['script-src']).to_not include('https://example.com/myscript.js')
|
|
end
|
|
end
|
|
|
|
it 'can be extended by site setting' do
|
|
SiteSetting.content_security_policy_script_src = 'from-site-setting.com|from-site-setting.net'
|
|
|
|
expect(parse(policy)['script-src']).to include('from-site-setting.com', 'from-site-setting.net')
|
|
end
|
|
|
|
def parse(csp_string)
|
|
csp_string.split(';').map do |policy|
|
|
directive, *sources = policy.split
|
|
[directive, sources]
|
|
end.to_h
|
|
end
|
|
|
|
def policy(theme_id = nil, path_info: "/")
|
|
ContentSecurityPolicy.policy(theme_id, path_info: path_info)
|
|
end
|
|
end
|