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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 190 additions and 152 deletions

View File

@ -80,8 +80,23 @@ function updateScriptReferences({
entrypointName === "discourse" && entrypointName === "discourse" &&
element.tagName.toLowerCase() === "script" element.tagName.toLowerCase() === "script"
) { ) {
let nonce = "";
for (const [attr, value] of element.attributes) {
if (attr === "nonce") {
nonce = value;
break;
}
}
if (!nonce) {
// eslint-disable-next-line no-console
console.error(
"Expected to find a nonce= attribute on the main discourse script tag, but none was found. ember-cli-live-reload may not work correctly."
);
}
newElements.unshift( newElements.unshift(
`<script async src="${baseURL}ember-cli-live-reload.js"></script>` `<script async src="${baseURL}ember-cli-live-reload.js" nonce="${nonce}"></script>`
); );
} }
@ -159,7 +174,7 @@ async function handleRequest(proxy, baseURL, req, res, outputPath) {
} }
const csp = response.headers.get("content-security-policy"); const csp = response.headers.get("content-security-policy");
if (csp) { if (csp && !csp.includes("'strict-dynamic'")) {
const emberCliAdditions = [ const emberCliAdditions = [
`http://${originalHost}${baseURL}assets/`, `http://${originalHost}${baseURL}assets/`,
`http://${originalHost}${baseURL}ember-cli-live-reload.js`, `http://${originalHost}${baseURL}ember-cli-live-reload.js`,

View File

@ -65,10 +65,13 @@ module ApplicationHelper
google_universal_analytics_json google_universal_analytics_json
end end
def google_tag_manager_nonce_placeholder def csp_nonce_placeholder
placeholder = "[[csp_nonce_placeholder_#{SecureRandom.hex}]]" @csp_nonce_placeholder ||=
response.headers["Discourse-GTM-Nonce-Placeholder"] = placeholder begin
placeholder placeholder = "[[csp_nonce_placeholder_#{SecureRandom.hex}]]"
response.headers["Discourse-CSP-Nonce-Placeholder"] = placeholder
placeholder
end
end end
def shared_session_key def shared_session_key
@ -150,16 +153,17 @@ module ApplicationHelper
def preload_script_url(url, entrypoint: nil) def preload_script_url(url, entrypoint: nil)
entrypoint_attribute = entrypoint ? "data-discourse-entrypoint=\"#{entrypoint}\"" : "" entrypoint_attribute = entrypoint ? "data-discourse-entrypoint=\"#{entrypoint}\"" : ""
nonce_attribute = "nonce=\"#{csp_nonce_placeholder}\""
add_resource_preload_list(url, "script") add_resource_preload_list(url, "script")
if GlobalSetting.preload_link_header if GlobalSetting.preload_link_header
<<~HTML.html_safe <<~HTML.html_safe
<script defer src="#{url}" #{entrypoint_attribute}></script> <script defer src="#{url}" #{entrypoint_attribute} #{nonce_attribute}></script>
HTML HTML
else else
<<~HTML.html_safe <<~HTML.html_safe
<link rel="preload" href="#{url}" as="script" #{entrypoint_attribute}> <link rel="preload" href="#{url}" as="script" #{entrypoint_attribute} #{nonce_attribute}>
<script defer src="#{url}" #{entrypoint_attribute}></script> <script defer src="#{url}" #{entrypoint_attribute} #{nonce_attribute}></script>
HTML HTML
end end
end end
@ -586,6 +590,7 @@ module ApplicationHelper
mobile_view? ? :mobile : :desktop, mobile_view? ? :mobile : :desktop,
name, name,
skip_transformation: request.env[:skip_theme_ids_transformation].present?, skip_transformation: request.env[:skip_theme_ids_transformation].present?,
csp_nonce: csp_nonce_placeholder,
) )
end end
@ -595,6 +600,7 @@ module ApplicationHelper
:translations, :translations,
I18n.locale, I18n.locale,
skip_transformation: request.env[:skip_theme_ids_transformation].present?, skip_transformation: request.env[:skip_theme_ids_transformation].present?,
csp_nonce: csp_nonce_placeholder,
) )
end end
@ -604,6 +610,7 @@ module ApplicationHelper
:extra_js, :extra_js,
nil, nil,
skip_transformation: request.env[:skip_theme_ids_transformation].present?, skip_transformation: request.env[:skip_theme_ids_transformation].present?,
csp_nonce: csp_nonce_placeholder,
) )
end end

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
# Helper to render a no-op inline script tag to work around a safari bug
# which causes `defer` scripts to be run before stylesheets are loaded.
# https://bugs.webkit.org/show_bug.cgi?id=209261
module DeferScriptHelper
def self.safari_workaround_script
<<~HTML.html_safe
<script>#{raw_js}</script>
HTML
end
def self.fingerprint
@fingerprint ||= calculate_fingerprint
end
private
def self.raw_js
"/* Workaround for https://bugs.webkit.org/show_bug.cgi?id=209261 */"
end
def self.calculate_fingerprint
"sha256-#{Digest::SHA256.base64digest(raw_js)}"
end
end

View File

@ -1,18 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
module SplashScreenHelper module SplashScreenHelper
def self.inline_splash_screen_script def self.raw_js
<<~HTML.html_safe
<script>#{raw_js}</script>
HTML
end
def self.fingerprint
if Rails.env.development? if Rails.env.development?
calculate_fingerprint load_js
else else
@fingerprint ||= calculate_fingerprint @loaded_js ||= load_js
end end.html_safe
end end
private private
@ -26,16 +20,4 @@ module SplashScreenHelper
Rails.logger.error("Unable to load splash screen JS") if Rails.env.production? Rails.logger.error("Unable to load splash screen JS") if Rails.env.production?
"console.log('Unable to load splash screen JS')" "console.log('Unable to load splash screen JS')"
end end
def self.raw_js
if Rails.env.development?
load_js
else
@loaded_js ||= load_js
end
end
def self.calculate_fingerprint
"sha256-#{Digest::SHA256.base64digest(raw_js)}"
end
end end

View File

@ -6,7 +6,7 @@ require "json_schemer"
class Theme < ActiveRecord::Base class Theme < ActiveRecord::Base
include GlobalPath include GlobalPath
BASE_COMPILER_VERSION = 78 BASE_COMPILER_VERSION = 80
class SettingsMigrationError < StandardError class SettingsMigrationError < StandardError
end end
@ -356,11 +356,13 @@ class Theme < ActiveRecord::Base
end end
end end
def self.lookup_field(theme_id, target, field, skip_transformation: false) def self.lookup_field(theme_id, target, field, skip_transformation: false, csp_nonce: nil)
return "" if theme_id.blank? return "" if theme_id.blank?
theme_ids = !skip_transformation ? transform_ids(theme_id) : [theme_id] theme_ids = !skip_transformation ? transform_ids(theme_id) : [theme_id]
(resolve_baked_field(theme_ids, target.to_sym, field) || "").html_safe resolved = (resolve_baked_field(theme_ids, target.to_sym, field) || "")
resolved = resolved.gsub(ThemeField::CSP_NONCE_PLACEHOLDER, csp_nonce) if csp_nonce
resolved.html_safe
end end
def self.lookup_modifier(theme_ids, modifier_name) def self.lookup_modifier(theme_ids, modifier_name)
@ -469,8 +471,8 @@ class Theme < ActiveRecord::Base
.compact .compact
caches.map { |c| <<~HTML.html_safe }.join("\n") caches.map { |c| <<~HTML.html_safe }.join("\n")
<link rel="preload" href="#{c.url}" as="script"> <link rel="preload" href="#{c.url}" as="script" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}">
<script defer src='#{c.url}' data-theme-id='#{c.theme_id}'></script> <script defer src="#{c.url}" data-theme-id="#{c.theme_id}" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}"></script>
HTML HTML
end end
when :translations when :translations

View File

@ -3,6 +3,9 @@
class ThemeField < ActiveRecord::Base class ThemeField < ActiveRecord::Base
MIGRATION_NAME_PART_MAX_LENGTH = 150 MIGRATION_NAME_PART_MAX_LENGTH = 150
# This string is not 'secret'. It's just randomized to avoid accidental clashes with genuine theme field content.
CSP_NONCE_PLACEHOLDER = "__CSP__NONCE__PLACEHOLDER__f72bff1b1768168a34ee092ce759f192__"
belongs_to :upload belongs_to :upload
has_one :javascript_cache, dependent: :destroy has_one :javascript_cache, dependent: :destroy
has_one :upload_reference, as: :target, dependent: :destroy has_one :upload_reference, as: :target, dependent: :destroy
@ -168,12 +171,15 @@ class ThemeField < ActiveRecord::Base
doc doc
.css("script") .css("script")
.each_with_index do |node, index| .each_with_index do |node, index|
next unless inline_javascript?(node) if inline_javascript?(node)
js_compiler.append_raw_script( js_compiler.append_raw_script(
"_html/#{Theme.targets[self.target_id]}/#{name}_#{index + 1}.js", "_html/#{Theme.targets[self.target_id]}/#{name}_#{index + 1}.js",
node.inner_html, node.inner_html,
) )
node.remove node.remove
else
node["nonce"] = CSP_NONCE_PLACEHOLDER
end
end end
settings_hash = theme.build_settings_hash settings_hash = theme.build_settings_hash
@ -185,9 +191,9 @@ class ThemeField < ActiveRecord::Base
javascript_cache.save! javascript_cache.save!
doc.add_child(<<~HTML.html_safe) if javascript_cache.content.present? doc.add_child(<<~HTML.html_safe) if javascript_cache.content.present?
<link rel="preload" href="#{javascript_cache.url}" as="script"> <link rel="preload" href="#{javascript_cache.url}" as="script" nonce="#{CSP_NONCE_PLACEHOLDER}">
<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script> <script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}' nonce="#{CSP_NONCE_PLACEHOLDER}"></script>
HTML HTML
[doc.to_s, errors&.join("\n")] [doc.to_s, errors&.join("\n")]
end end
@ -294,8 +300,8 @@ class ThemeField < ActiveRecord::Base
javascript_cache.save! javascript_cache.save!
doc = "" doc = ""
doc = <<~HTML.html_safe if javascript_cache.content.present? doc = <<~HTML.html_safe if javascript_cache.content.present?
<link rel="preload" href="#{javascript_cache.url}" as="script"> <link rel="preload" href="#{javascript_cache.url}" as="script" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}">
<script defer src='#{javascript_cache.url}' data-theme-id='#{theme_id}'></script> <script defer src="#{javascript_cache.url}" data-theme-id="#{theme_id}" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}"></script>
HTML HTML
[doc, errors&.join("\n")] [doc, errors&.join("\n")]
end end

View File

@ -246,6 +246,8 @@
</style> </style>
</noscript> </noscript>
<%= SplashScreenHelper.inline_splash_screen_script %> <script nonce="<%= csp_nonce_placeholder %>">
<%= SplashScreenHelper.raw_js %>
</script>
</section> </section>
<%- end %> <%- end %>

View File

@ -1,6 +1,6 @@
<meta id="data-google-tag-manager" <meta id="data-google-tag-manager"
data-data-layer="<%= google_tag_manager_json %>" data-data-layer="<%= google_tag_manager_json %>"
data-nonce="<%= google_tag_manager_nonce_placeholder %>" data-nonce="<%= csp_nonce_placeholder %>"
data-container-id="<%= SiteSetting.gtm_container_id %>" /> data-container-id="<%= SiteSetting.gtm_container_id %>" />
<%= preload_script 'google-tag-manager' %> <%= preload_script 'google-tag-manager' %>

View File

@ -7,6 +7,6 @@
<% if SiteSetting.ga_version == "v3_analytics" %> <% if SiteSetting.ga_version == "v3_analytics" %>
<%= preload_script "google-universal-analytics-v3" %> <%= preload_script "google-universal-analytics-v3" %>
<% elsif SiteSetting.ga_version == "v4_gtag" %> <% elsif SiteSetting.ga_version == "v4_gtag" %>
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= SiteSetting.ga_universal_tracking_code %>"></script> <script async src="https://www.googletagmanager.com/gtag/js?id=<%= SiteSetting.ga_universal_tracking_code %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%= preload_script "google-universal-analytics-v4" %> <%= preload_script "google-universal-analytics-v4" %>
<% end %> <% end %>

View File

@ -27,8 +27,8 @@
<% add_resource_preload_list(script_asset_path("start-discourse"), "script") %> <% add_resource_preload_list(script_asset_path("start-discourse"), "script") %>
<% add_resource_preload_list(script_asset_path("browser-update"), "script") %> <% add_resource_preload_list(script_asset_path("browser-update"), "script") %>
<%- else %> <%- else %>
<link rel="preload" href="<%= script_asset_path "start-discourse" %>" as="script"> <link rel="preload" href="<%= script_asset_path "start-discourse" %>" as="script" nonce="<%= csp_nonce_placeholder %>">
<link rel="preload" href="<%= script_asset_path "browser-update" %>" as="script"> <link rel="preload" href="<%= script_asset_path "browser-update" %>" as="script" nonce="<%= csp_nonce_placeholder %>">
<%- end %> <%- end %>
<%= preload_script 'browser-detect' %> <%= preload_script 'browser-detect' %>
@ -132,11 +132,11 @@
</form> </form>
<% end %> <% end %>
<script defer src="<%= script_asset_path "start-discourse" %>"></script> <script defer src="<%= script_asset_path "start-discourse" %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%= yield :data %> <%= yield :data %>
<script defer src="<%= script_asset_path "browser-update" %>"></script> <script defer src="<%= script_asset_path "browser-update" %>" nonce="<%= csp_nonce_placeholder %>"></script>
<%- unless customization_disabled? %> <%- unless customization_disabled? %>
<%= theme_lookup("body_tag") %> <%= theme_lookup("body_tag") %>
@ -146,6 +146,6 @@
<%= build_plugin_html 'server:before-body-close' %> <%= build_plugin_html 'server:before-body-close' %>
<%- end %> <%- end %>
<%= DeferScriptHelper.safari_workaround_script %> <script nonce="<%= csp_nonce_placeholder %>">/* Workaround for https://bugs.webkit.org/show_bug.cgi?id=209261 */</script>
</body> </body>
</html> </html>

View File

@ -167,8 +167,8 @@ module Discourse
config.middleware.swap ActionDispatch::ContentSecurityPolicy::Middleware, config.middleware.swap ActionDispatch::ContentSecurityPolicy::Middleware,
ContentSecurityPolicy::Middleware ContentSecurityPolicy::Middleware
require "middleware/gtm_script_nonce_injector" require "middleware/csp_script_nonce_injector"
config.middleware.insert_after(ActionDispatch::Flash, Middleware::GtmScriptNonceInjector) config.middleware.insert_after(ActionDispatch::Flash, Middleware::CspScriptNonceInjector)
require "middleware/discourse_public_exceptions" require "middleware/discourse_public_exceptions"
config.exceptions_app = Middleware::DiscoursePublicExceptions.new(Rails.public_path) config.exceptions_app = Middleware::DiscoursePublicExceptions.new(Rails.public_path)

View File

@ -88,6 +88,13 @@ if defined?(Rack::MiniProfiler) && defined?(Rack::MiniProfiler::Config)
Rack::MiniProfiler.config.max_traces_to_show = 100 if Rails.env.development? Rack::MiniProfiler.config.max_traces_to_show = 100 if Rails.env.development?
Rack::MiniProfiler.config.content_security_policy_nonce =
Proc.new do |env, headers|
if csp = headers["Content-Security-Policy"]
csp[/script-src[^;]+'nonce-([^']+)'/, 1]
end
end
Rack::MiniProfiler.counter_method(Redis::Client, :call) { "redis" } Rack::MiniProfiler.counter_method(Redis::Client, :call) { "redis" }
# Rack::MiniProfiler.counter_method(ActiveRecord::QueryMethods, 'build_arel') # Rack::MiniProfiler.counter_method(ActiveRecord::QueryMethods, 'build_arel')
# Rack::MiniProfiler.counter_method(Array, 'uniq') # Rack::MiniProfiler.counter_method(Array, 'uniq')

View File

@ -11,6 +11,6 @@ enabled =
if !ENV["DISCOURSE_DISABLE_ANON_CACHE"] && enabled if !ENV["DISCOURSE_DISABLE_ANON_CACHE"] && enabled
# in an ideal world this is position 0, but mobile detection uses ... session and request and params # in an ideal world this is position 0, but mobile detection uses ... session and request and params
Rails.configuration.middleware.insert_after Middleware::GtmScriptNonceInjector, Rails.configuration.middleware.insert_after Middleware::CspScriptNonceInjector,
Middleware::AnonymousCache Middleware::AnonymousCache
end end

View File

@ -1710,6 +1710,7 @@ en:
content_security_policy_collect_reports: "Enable CSP violation report collection at /csp_reports" content_security_policy_collect_reports: "Enable CSP violation report collection at /csp_reports"
content_security_policy_frame_ancestors: "Restrict who can embed this site in iframes via CSP. Control allowed hosts on <a href='%{base_path}/admin/customize/embedding'>Embedding</a>" content_security_policy_frame_ancestors: "Restrict who can embed this site in iframes via CSP. Control allowed hosts on <a href='%{base_path}/admin/customize/embedding'>Embedding</a>"
content_security_policy_script_src: "Additional allowlisted script sources. The current host and CDN are included by default. See <a href='https://meta.discourse.org/t/mitigate-xss-attacks-with-content-security-policy/104243' target='_blank'>Mitigate XSS Attacks with Content Security Policy.</a> (CSP)" content_security_policy_script_src: "Additional allowlisted script sources. The current host and CDN are included by default. See <a href='https://meta.discourse.org/t/mitigate-xss-attacks-with-content-security-policy/104243' target='_blank'>Mitigate XSS Attacks with Content Security Policy.</a> (CSP)"
content_security_policy_strict_dynamic: "EXPERIMENTAL: Use a strict-dynamic content security policy. This is more modern and more flexible than our default CSP configuration. This is fully functional, but not yet tested with all themes and plugins."
invalidate_inactive_admin_email_after_days: "Admin accounts that have not visited the site in this number of days will need to re-validate their email address before logging in. Set to 0 to disable." invalidate_inactive_admin_email_after_days: "Admin accounts that have not visited the site in this number of days will need to re-validate their email address before logging in. Set to 0 to disable."
include_secure_categories_in_tag_counts: "When enabled, count of topics for a tag will include topics that are in read restricted categories for all users. When disabled, normal users are only shown a count of topics for a tag where all the topics are in public categories." include_secure_categories_in_tag_counts: "When enabled, count of topics for a tag will include topics that are in read restricted categories for all users. When disabled, normal users are only shown a count of topics for a tag where all the topics are in public categories."
display_personal_messages_tag_counts: "When enabled, count of personal messages tagged with a given tag will be displayed." display_personal_messages_tag_counts: "When enabled, count of personal messages tagged with a given tag will be displayed."

View File

@ -1985,6 +1985,8 @@ security:
content_security_policy_script_src: content_security_policy_script_src:
type: simple_list type: simple_list
default: "" default: ""
content_security_policy_strict_dynamic:
default: false
invalidate_inactive_admin_email_after_days: invalidate_inactive_admin_email_after_days:
default: 365 default: 365
min: 0 min: 0

View File

@ -74,6 +74,15 @@ class ContentSecurityPolicy
@directives[directive] ||= [] @directives[directive] ||= []
sources = Array(sources).map { |s| normalize_source(s) } sources = Array(sources).map { |s| normalize_source(s) }
if SiteSetting.content_security_policy_strict_dynamic
# Strip any sources which are ignored under strict-dynamic
# If/when we make strict-dynamic the only option, we could print deprecation warnings
# asking plugin/theme authors to remove the unnecessary config
sources =
sources.reject { |s| s == "'unsafe-inline'" || s == "'self'" || !s.start_with?("'") }
end
@directives[directive].concat(sources) @directives[directive].concat(sources)
@directives[directive].delete(:none) if @directives[directive].count > 1 @directives[directive].delete(:none) if @directives[directive].count > 1

View File

@ -60,14 +60,17 @@ class ContentSecurityPolicy
end end
def script_src def script_src
[ sources = []
"#{base_url}/logs/",
"#{base_url}/sidekiq/", if SiteSetting.content_security_policy_strict_dynamic
"#{base_url}/mini-profiler-resources/", sources << "'strict-dynamic'"
*script_assets, else
].tap do |sources| sources.push(
sources << :report_sample if SiteSetting.content_security_policy_collect_reports "#{base_url}/logs/",
sources << :unsafe_eval if Rails.env.development? # TODO remove this once we have proper source maps in dev "#{base_url}/sidekiq/",
"#{base_url}/mini-profiler-resources/",
*script_assets,
)
# Support Ember CLI Live reload # Support Ember CLI Live reload
if Rails.env.development? if Rails.env.development?
@ -85,13 +88,15 @@ class ContentSecurityPolicy
if SiteSetting.gtm_container_id.present? if SiteSetting.gtm_container_id.present?
sources << "https://www.googletagmanager.com/gtm.js" sources << "https://www.googletagmanager.com/gtm.js"
end end
sources << "'#{SplashScreenHelper.fingerprint}'" if SiteSetting.splash_screen
sources << "'#{DeferScriptHelper.fingerprint}'"
end end
sources << :report_sample if SiteSetting.content_security_policy_collect_reports
sources
end end
def worker_src def worker_src
return [] if SiteSetting.content_security_policy_strict_dynamic
[ [
"'self'", # For service worker "'self'", # For service worker
*script_assets(worker: true), *script_assets(worker: true),

View File

@ -56,38 +56,40 @@ class ContentSecurityPolicy
ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions, ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions,
) )
html_fields = if !SiteSetting.content_security_policy_strict_dynamic
ThemeField.where( html_fields =
theme_id: theme_ids, ThemeField.where(
target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] }, theme_id: theme_ids,
name: ThemeField.html_fields, target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] },
) name: ThemeField.html_fields,
)
auto_script_src_extension = { script_src: [] } auto_script_src_extension = { script_src: [] }
html_fields.each(&:ensure_baked!) html_fields.each(&:ensure_baked!)
doc = html_fields.map(&:value_baked).join("\n") doc = html_fields.map(&:value_baked).join("\n")
Nokogiri::HTML5 Nokogiri::HTML5
.fragment(doc) .fragment(doc)
.css("script[src]") .css("script[src]")
.each do |node| .each do |node|
src = node["src"] src = node["src"]
uri = URI(src) uri = URI(src)
next if GlobalSetting.cdn_url && src.starts_with?(GlobalSetting.cdn_url) # Ignore CDN urls (theme-javascripts) next if GlobalSetting.cdn_url && src.starts_with?(GlobalSetting.cdn_url) # Ignore CDN urls (theme-javascripts)
next if uri.host.nil? # Ignore same-domain scripts (theme-javascripts) next if uri.host.nil? # Ignore same-domain scripts (theme-javascripts)
next if uri.path.nil? # Ignore raw hosts next if uri.path.nil? # Ignore raw hosts
uri.query = nil # CSP should not include query part of url uri.query = nil # CSP should not include query part of url
uri_string = uri.to_s.sub(%r{\A//}, "") # Protocol-less CSP should not have // at beginning of URL uri_string = uri.to_s.sub(%r{\A//}, "") # Protocol-less CSP should not have // at beginning of URL
auto_script_src_extension[:script_src] << uri_string auto_script_src_extension[:script_src] << uri_string
rescue URI::Error rescue URI::Error
# Ignore invalid URI # Ignore invalid URI
end end
extensions << auto_script_src_extension extensions << auto_script_src_extension
end
extensions extensions
end end

View File

@ -11,6 +11,7 @@ class ContentSecurityPolicy
request = Rack::Request.new(env) request = Rack::Request.new(env)
_, headers, _ = response = @app.call(env) _, headers, _ = response = @app.call(env)
return response if headers["Content-Security-Policy"].present?
return response unless html_response?(headers) return response unless html_response?(headers)
# The EnforceHostname middleware ensures request.host_with_port can be trusted # The EnforceHostname middleware ensures request.host_with_port can be trusted

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Middleware module Middleware
class GtmScriptNonceInjector class CspScriptNonceInjector
def initialize(app, settings = {}) def initialize(app, settings = {})
@app = app @app = app
end end
@ -9,10 +9,10 @@ module Middleware
def call(env) def call(env)
status, headers, response = @app.call(env) status, headers, response = @app.call(env)
if nonce_placeholder = headers.delete("Discourse-GTM-Nonce-Placeholder") if nonce_placeholder = headers.delete("Discourse-CSP-Nonce-Placeholder")
nonce = SecureRandom.hex nonce = SecureRandom.alphanumeric(25)
parts = [] parts = []
response.each { |part| parts << part.to_s.sub(nonce_placeholder, nonce) } response.each { |part| parts << part.to_s.gsub(nonce_placeholder, nonce) }
%w[Content-Security-Policy Content-Security-Policy-Report-Only].each do |name| %w[Content-Security-Policy Content-Security-Policy-Report-Only].each do |name|
next if headers[name].blank? next if headers[name].blank?
headers[name] = headers[name].sub("script-src ", "script-src 'nonce-#{nonce}' ") headers[name] = headers[name].sub("script-src ", "script-src 'nonce-#{nonce}' ")

View File

@ -3,10 +3,10 @@
RSpec.describe ApplicationHelper do RSpec.describe ApplicationHelper do
describe "preload_script" do describe "preload_script" do
def script_tag(url, entrypoint) def script_tag(url, entrypoint, nonce)
<<~HTML <<~HTML
<link rel="preload" href="#{url}" as="script" data-discourse-entrypoint="#{entrypoint}"> <link rel="preload" href="#{url}" as="script" data-discourse-entrypoint="#{entrypoint}" nonce="#{nonce}">
<script defer src="#{url}" data-discourse-entrypoint="#{entrypoint}"></script> <script defer src="#{url}" data-discourse-entrypoint="#{entrypoint}" nonce="#{nonce}"></script>
HTML HTML
end end
@ -58,7 +58,11 @@ RSpec.describe ApplicationHelper do
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq( 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 end
@ -66,7 +70,11 @@ RSpec.describe ApplicationHelper do
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq( 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 end
@ -74,7 +82,11 @@ RSpec.describe ApplicationHelper do
helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip" helper.request.env["HTTP_ACCEPT_ENCODING"] = "gzip"
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq( 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 end
@ -83,7 +95,11 @@ RSpec.describe ApplicationHelper do
link = helper.preload_script("start-discourse") link = helper.preload_script("start-discourse")
expect(link).to eq( 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 end
@ -94,6 +110,7 @@ RSpec.describe ApplicationHelper do
script_tag( script_tag(
"https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js", "https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js",
"discourse/tests/theme_qunit_ember_jquery", "discourse/tests/theme_qunit_ember_jquery",
helper.csp_nonce_placeholder,
), ),
) )
end end

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 = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html)
theme_field.ensure_baked! theme_field.ensure_baked!
expect(theme_field.value_baked).to include( 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("external-script.js")
expect(theme_field.value_baked).to include('<script type="text/template"') expect(theme_field.value_baked).to include('<script type="text/template"')
@ -120,7 +120,7 @@ HTML
field.ensure_baked! field.ensure_baked!
expect(field.error).not_to eq(nil) expect(field.error).not_to eq(nil)
expect(field.value_baked).to include( 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") expect(field.javascript_cache.content).to include("[THEME 1 'Default'] Compile error")
@ -147,7 +147,7 @@ HTML
javascript_cache = theme_field.javascript_cache javascript_cache = theme_field.javascript_cache
expect(theme_field.value_baked).to include( 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("testing-div")
expect(javascript_cache.content).to include("string_setting") expect(javascript_cache.content).to include("string_setting")
@ -590,7 +590,7 @@ HTML
it "is generated correctly" do it "is generated correctly" do
fr1.ensure_baked! fr1.ensure_baked!
expect(fr1.value_baked).to include( 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("bonjourworld")
expect(fr1.javascript_cache.content).to include("helloworld") expect(fr1.javascript_cache.content).to include("helloworld")

View File

@ -699,7 +699,7 @@ RSpec.describe ApplicationController do
get "/latest" get "/latest"
expect(response.headers["X-Discourse-Cached"]).to eq("store") 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"] script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
report_only_script_src = report_only_script_src =
@ -716,7 +716,7 @@ RSpec.describe ApplicationController do
get "/latest" get "/latest"
expect(response.headers["X-Discourse-Cached"]).to eq("true") 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"] script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
report_only_script_src = report_only_script_src =
@ -732,19 +732,6 @@ RSpec.describe ApplicationController do
expect(gtm_meta_tag["data-nonce"]).to eq(second_nonce) expect(gtm_meta_tag["data-nonce"]).to eq(second_nonce)
end 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) def parse(csp_string)
csp_string csp_string
.split(";") .split(";")
@ -756,7 +743,7 @@ RSpec.describe ApplicationController do
end end
def extract_nonce_from_script_src(script_src) 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 expect(nonce).to be_present
nonce nonce
end end

View File

@ -66,7 +66,6 @@ module SystemHelpers
def setup_system_test def setup_system_test
SiteSetting.login_required = false SiteSetting.login_required = false
SiteSetting.has_login_hint = false SiteSetting.has_login_hint = false
SiteSetting.content_security_policy = false
SiteSetting.force_hostname = Capybara.server_host SiteSetting.force_hostname = Capybara.server_host
SiteSetting.port = Capybara.server_port SiteSetting.port = Capybara.server_port
SiteSetting.external_system_avatars_enabled = false SiteSetting.external_system_avatars_enabled = false

View File

@ -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