discourse/lib/content_security_policy/extension.rb
David Taylor b1f74ab59e
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.
2024-02-16 11:16:54 +00:00

109 lines
3.1 KiB
Ruby

# frozen_string_literal: true
class ContentSecurityPolicy
module Extension
extend self
def site_setting_extension
{ script_src: SiteSetting.content_security_policy_script_src.split("|") }
end
def path_specific_extension(path_info)
{}.tap do |obj|
for_qunit_route = !Rails.env.production? && %w[/qunit /wizard/qunit].include?(path_info)
for_qunit_route ||= "/theme-qunit" == path_info
obj[:script_src] = :unsafe_eval if for_qunit_route
end
end
def plugin_extensions
[].tap do |extensions|
Discourse.plugins.each do |plugin|
extensions.concat(plugin.csp_extensions) if plugin.enabled?
end
end
end
THEME_SETTING = "extend_content_security_policy"
def theme_extensions(theme_id)
key = "theme_extensions_#{theme_id}"
cache.defer_get_set(key) { find_theme_extensions(theme_id) }
end
def clear_theme_extensions_cache!
cache.clear
end
private
def cache
@cache ||= DistributedCache.new("csp_extensions")
end
def find_theme_extensions(theme_id)
extensions = []
theme_ids = Theme.transform_ids(theme_id)
Theme
.where(id: theme_ids)
.find_each do |theme|
theme.cached_settings.each do |setting, value|
extensions << build_theme_extension(value.split("|")) if setting.to_s == THEME_SETTING
end
end
extensions << build_theme_extension(
ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions,
)
if !SiteSetting.content_security_policy_strict_dynamic
html_fields =
ThemeField.where(
theme_id: theme_ids,
target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] },
name: ThemeField.html_fields,
)
auto_script_src_extension = { script_src: [] }
html_fields.each(&:ensure_baked!)
doc = html_fields.map(&:value_baked).join("\n")
Nokogiri::HTML5
.fragment(doc)
.css("script[src]")
.each do |node|
src = node["src"]
uri = URI(src)
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.path.nil? # Ignore raw hosts
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
auto_script_src_extension[:script_src] << uri_string
rescue URI::Error
# Ignore invalid URI
end
extensions << auto_script_src_extension
end
extensions
end
def build_theme_extension(entries)
{}.tap do |extension|
entries.each do |entry|
directive, source = entry.split(":", 2).map(&:strip)
extension[directive] ||= []
extension[directive] << source
end
end
end
end
end