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:
David Taylor
2024-02-16 11:16:54 +00:00
committed by GitHub
parent 9329a5395a
commit b1f74ab59e
25 changed files with 190 additions and 152 deletions
+24 -7
View File
@@ -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
+4 -4
View File
@@ -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")
+3 -16
View File
@@ -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
-1
View File
@@ -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