mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Add experimental option for strict-dynamic CSP (#25664)
The strict-dynamic CSP directive is supported in all our target browsers, and makes for a much simpler configuration. Instead of allowlisting paths, we use a per-request nonce to authorize `<script>` tags, and then those scripts are allowed to load additional scripts (or add additional inline scripts) without restriction. This becomes especially useful when admins want to add external scripts like Google Tag Manager, or advertising scripts, which then go on to load a ton of other scripts. All script tags introduced via themes will automatically have the nonce attribute applied, so it should be zero-effort for theme developers. Plugins *may* need some changes if they are inserting their own script tags. This commit introduces a strict-dynamic-based CSP behind an experimental `content_security_policy_strict_dynamic` site setting.
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
|
||||
RSpec.describe ApplicationHelper do
|
||||
describe "preload_script" do
|
||||
def script_tag(url, entrypoint)
|
||||
def script_tag(url, entrypoint, nonce)
|
||||
<<~HTML
|
||||
<link rel="preload" href="#{url}" as="script" data-discourse-entrypoint="#{entrypoint}">
|
||||
<script defer src="#{url}" data-discourse-entrypoint="#{entrypoint}"></script>
|
||||
<link rel="preload" href="#{url}" as="script" data-discourse-entrypoint="#{entrypoint}" nonce="#{nonce}">
|
||||
<script defer src="#{url}" data-discourse-entrypoint="#{entrypoint}" nonce="#{nonce}"></script>
|
||||
HTML
|
||||
end
|
||||
|
||||
@@ -58,7 +58,11 @@ RSpec.describe ApplicationHelper do
|
||||
link = helper.preload_script("start-discourse")
|
||||
|
||||
expect(link).to eq(
|
||||
script_tag("https://s3cdn.com/assets/start-discourse.br.js", "start-discourse"),
|
||||
script_tag(
|
||||
"https://s3cdn.com/assets/start-discourse.br.js",
|
||||
"start-discourse",
|
||||
helper.csp_nonce_placeholder,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
@@ -66,7 +70,11 @@ RSpec.describe ApplicationHelper do
|
||||
link = helper.preload_script("start-discourse")
|
||||
|
||||
expect(link).to eq(
|
||||
script_tag("https://s3cdn.com/assets/start-discourse.js", "start-discourse"),
|
||||
script_tag(
|
||||
"https://s3cdn.com/assets/start-discourse.js",
|
||||
"start-discourse",
|
||||
helper.csp_nonce_placeholder,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
@@ -74,7 +82,11 @@ RSpec.describe ApplicationHelper do
|
||||
helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip"
|
||||
link = helper.preload_script("start-discourse")
|
||||
expect(link).to eq(
|
||||
script_tag("https://s3cdn.com/assets/start-discourse.gz.js", "start-discourse"),
|
||||
script_tag(
|
||||
"https://s3cdn.com/assets/start-discourse.gz.js",
|
||||
"start-discourse",
|
||||
helper.csp_nonce_placeholder,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
@@ -83,7 +95,11 @@ RSpec.describe ApplicationHelper do
|
||||
link = helper.preload_script("start-discourse")
|
||||
|
||||
expect(link).to eq(
|
||||
script_tag("https://s3cdn.com/assets/start-discourse.js", "start-discourse"),
|
||||
script_tag(
|
||||
"https://s3cdn.com/assets/start-discourse.js",
|
||||
"start-discourse",
|
||||
helper.csp_nonce_placeholder,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
@@ -94,6 +110,7 @@ RSpec.describe ApplicationHelper do
|
||||
script_tag(
|
||||
"https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js",
|
||||
"discourse/tests/theme_qunit_ember_jquery",
|
||||
helper.csp_nonce_placeholder,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
@@ -83,7 +83,7 @@ RSpec.describe ThemeField do
|
||||
theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html)
|
||||
theme_field.ensure_baked!
|
||||
expect(theme_field.value_baked).to include(
|
||||
"<script defer=\"\" src=\"#{theme_field.javascript_cache.url}\" data-theme-id=\"1\"></script>",
|
||||
"<script defer=\"\" src=\"#{theme_field.javascript_cache.url}\" data-theme-id=\"1\" nonce=\"#{ThemeField::CSP_NONCE_PLACEHOLDER}\"></script>",
|
||||
)
|
||||
expect(theme_field.value_baked).to include("external-script.js")
|
||||
expect(theme_field.value_baked).to include('<script type="text/template"')
|
||||
@@ -120,7 +120,7 @@ HTML
|
||||
field.ensure_baked!
|
||||
expect(field.error).not_to eq(nil)
|
||||
expect(field.value_baked).to include(
|
||||
"<script defer=\"\" src=\"#{field.javascript_cache.url}\" data-theme-id=\"1\"></script>",
|
||||
"<script defer=\"\" src=\"#{field.javascript_cache.url}\" data-theme-id=\"1\" nonce=\"#{ThemeField::CSP_NONCE_PLACEHOLDER}\"></script>",
|
||||
)
|
||||
expect(field.javascript_cache.content).to include("[THEME 1 'Default'] Compile error")
|
||||
|
||||
@@ -147,7 +147,7 @@ HTML
|
||||
javascript_cache = theme_field.javascript_cache
|
||||
|
||||
expect(theme_field.value_baked).to include(
|
||||
"<script defer=\"\" src=\"#{javascript_cache.url}\" data-theme-id=\"1\"></script>",
|
||||
"<script defer=\"\" src=\"#{javascript_cache.url}\" data-theme-id=\"1\" nonce=\"#{ThemeField::CSP_NONCE_PLACEHOLDER}\"></script>",
|
||||
)
|
||||
expect(javascript_cache.content).to include("testing-div")
|
||||
expect(javascript_cache.content).to include("string_setting")
|
||||
@@ -590,7 +590,7 @@ HTML
|
||||
it "is generated correctly" do
|
||||
fr1.ensure_baked!
|
||||
expect(fr1.value_baked).to include(
|
||||
"<script defer src='#{fr1.javascript_cache.url}' data-theme-id='#{fr1.theme_id}'></script>",
|
||||
"<script defer src=\"#{fr1.javascript_cache.url}\" data-theme-id=\"#{fr1.theme_id}\" nonce=\"#{ThemeField::CSP_NONCE_PLACEHOLDER}\"></script>",
|
||||
)
|
||||
expect(fr1.javascript_cache.content).to include("bonjourworld")
|
||||
expect(fr1.javascript_cache.content).to include("helloworld")
|
||||
|
||||
@@ -699,7 +699,7 @@ RSpec.describe ApplicationController do
|
||||
get "/latest"
|
||||
|
||||
expect(response.headers["X-Discourse-Cached"]).to eq("store")
|
||||
expect(response.headers).not_to include("Discourse-GTM-Nonce-Placeholder")
|
||||
expect(response.headers).not_to include("Discourse-CSP-Nonce-Placeholder")
|
||||
|
||||
script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
|
||||
report_only_script_src =
|
||||
@@ -716,7 +716,7 @@ RSpec.describe ApplicationController do
|
||||
get "/latest"
|
||||
|
||||
expect(response.headers["X-Discourse-Cached"]).to eq("true")
|
||||
expect(response.headers).not_to include("Discourse-GTM-Nonce-Placeholder")
|
||||
expect(response.headers).not_to include("Discourse-CSP-Nonce-Placeholder")
|
||||
|
||||
script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
|
||||
report_only_script_src =
|
||||
@@ -732,19 +732,6 @@ RSpec.describe ApplicationController do
|
||||
expect(gtm_meta_tag["data-nonce"]).to eq(second_nonce)
|
||||
end
|
||||
|
||||
it "when splash screen is enabled it adds the fingerprint to the policy" do
|
||||
SiteSetting.content_security_policy = true
|
||||
SiteSetting.splash_screen = true
|
||||
|
||||
get "/latest"
|
||||
fingerprint = SplashScreenHelper.fingerprint
|
||||
expect(response.headers).to include("Content-Security-Policy")
|
||||
|
||||
script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
|
||||
expect(script_src.to_s).to include(fingerprint)
|
||||
expect(response.body).to include(SplashScreenHelper.inline_splash_screen_script)
|
||||
end
|
||||
|
||||
def parse(csp_string)
|
||||
csp_string
|
||||
.split(";")
|
||||
@@ -756,7 +743,7 @@ RSpec.describe ApplicationController do
|
||||
end
|
||||
|
||||
def extract_nonce_from_script_src(script_src)
|
||||
nonce = script_src.find { |src| src.match?(/\A'nonce-\h{32}'\z/) }[-33...-1]
|
||||
nonce = script_src.lazy.map { |src| src[/\A'nonce-([^']+)'\z/, 1] }.find(&:itself)
|
||||
expect(nonce).to be_present
|
||||
nonce
|
||||
end
|
||||
|
||||
@@ -66,7 +66,6 @@ module SystemHelpers
|
||||
def setup_system_test
|
||||
SiteSetting.login_required = false
|
||||
SiteSetting.has_login_hint = false
|
||||
SiteSetting.content_security_policy = false
|
||||
SiteSetting.force_hostname = Capybara.server_host
|
||||
SiteSetting.port = Capybara.server_port
|
||||
SiteSetting.external_system_avatars_enabled = false
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe "Content security policy", type: :system do
|
||||
it "can boot the application in strict_dynamic mode" do
|
||||
expect(SiteSetting.content_security_policy).to eq(true)
|
||||
SiteSetting.content_security_policy_strict_dynamic = true
|
||||
|
||||
visit "/"
|
||||
expect(page).to have_css("#site-logo")
|
||||
end
|
||||
|
||||
it "can boot logster in strict_dynamic mode" do
|
||||
expect(SiteSetting.content_security_policy).to eq(true)
|
||||
sign_in Fabricate(:admin)
|
||||
SiteSetting.content_security_policy_strict_dynamic = true
|
||||
|
||||
visit "/logs"
|
||||
expect(page).to have_css("#log-table")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user