mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 14:03:22 +08: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:
parent
9329a5395a
commit
b1f74ab59e
|
@ -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`,
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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' %>
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}' ")
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
20
spec/system/content_security_policy_spec.rb
Normal file
20
spec/system/content_security_policy_spec.rb
Normal 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
|
Loading…
Reference in New Issue
Block a user