Skip to content

Commit 8bb1ca7

Browse files
authored
Merge pull request #4238 from AlchemyCMS/backport/8.4-stable/pr-4236
[8.4-stable] feat(csp): send a Content Security Policy with admin responses
2 parents aff639a + 6255c85 commit 8bb1ca7

11 files changed

Lines changed: 354 additions & 1 deletion

File tree

app/controllers/alchemy/admin/base_controller.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class BaseController < Alchemy::BaseController
1212
include Locale
1313
include Timezone
1414

15+
before_action :set_content_security_policy
1516
before_action :load_locked_pages
1617

1718
check_authorization
@@ -35,6 +36,25 @@ def leave
3536

3637
private
3738

39+
# Applies Alchemy's own Content Security Policy, but only if the host
40+
# application has opted in, and has not configured a policy of its own,
41+
# and the request is for one of Alchemy's own controllers.
42+
#
43+
# That last condition matters because controllers of the host
44+
# application can inherit from this class. Their views would not be ours
45+
# to make assumptions about.
46+
def set_content_security_policy
47+
policy_class = Alchemy.config.admin_content_security_policy
48+
return unless policy_class
49+
return unless controller_path.start_with?("alchemy/")
50+
return if request.content_security_policy
51+
52+
policy = policy_class.new(request)
53+
request.content_security_policy_nonce_generator ||= policy.nonce_generator
54+
request.content_security_policy = policy.call
55+
request.content_security_policy_report_only = policy.report_only?
56+
end
57+
3858
def safe_redirect_path(path = params[:redirect_to], fallback: admin_path)
3959
if is_safe_redirect_path?(path)
4060
path

app/views/layouts/alchemy/admin.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<link rel="shortcut icon" href="<%= asset_path('alchemy/favicon.ico') %>">
99
<link rel="preload" href="<%= asset_path("alchemy/icons-sprite.svg") %>" as="image" type="<%= Mime::Type.lookup_by_extension(:svg) %>" crossorigin>
1010
<%= csrf_meta_tag %>
11+
<%= csp_meta_tag %>
1112
<meta name="robots" content="noindex">
1213
<meta name="turbo-prefetch" content="false">
1314
<meta name="turbo-cache-control" content="no-cache">
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
module Admin
5+
# The Content Security Policy Alchemy applies to its own admin responses.
6+
#
7+
# Configured by default. Set it to nil to send no policy at all:
8+
#
9+
# Alchemy.config.admin_content_security_policy = nil
10+
#
11+
# 'self' follows the origin the admin is served from, so mounting it under
12+
# its own subdomain through +Alchemy.admin_constraints+ needs no extra
13+
# configuration. The page preview is same origin as well, because it falls
14+
# back to an admin path, unless a preview host is configured, in which case
15+
# that host is added to +frame-src+.
16+
#
17+
# Assets your application adds through +admin_stylesheets+ or through
18+
# +Alchemy.importmap+ are picked up automatically, so a module pinned to a
19+
# CDN does not need any extra configuration. Subclass to allow anything
20+
# else, and configure the subclass instead:
21+
#
22+
# class AdminContentSecurityPolicy < Alchemy::Admin::ContentSecurityPolicy
23+
# def call
24+
# super.tap do |policy|
25+
# policy.connect_src(*policy.directives["connect-src"], "https://api.example.com")
26+
# end
27+
# end
28+
# end
29+
#
30+
class ContentSecurityPolicy
31+
class InvalidSourceError < StandardError; end
32+
33+
# Rails calls an asset host proc with the asset source, so we need one to
34+
# evaluate it with. Any source resolves to the same origin unless the
35+
# host shards, which the %d form covers separately.
36+
ASSET_SOURCE = "/"
37+
38+
def initialize(request = nil)
39+
@request = request
40+
end
41+
42+
attr_reader :request
43+
# Send the policy as Content-Security-Policy-Report-Only, which reports
44+
# violations to the browser console and to +report_uri+ without blocking
45+
# anything. Worth running first, to find what a policy would break.
46+
def report_only? = false
47+
48+
# Authorizes the inline scripts Alchemy renders. It has to be
49+
# unguessable, otherwise injected markup could carry a valid nonce.
50+
def nonce_generator = ->(_request) { SecureRandom.base64(16) }
51+
52+
def call
53+
ActionDispatch::ContentSecurityPolicy.new do |policy|
54+
policy.default_src :self
55+
# script-src and style-src have to be named explicitly, because Rails
56+
# only appends the nonce to directives the policy actually declares.
57+
policy.script_src :self, *asset_hosts, *importmap_origins
58+
policy.style_src :self, *asset_hosts, *admin_stylesheet_origins
59+
# Inline style attributes cannot carry a nonce. Turbo sets them for
60+
# its progress bar, and so do several of our bundled dependencies.
61+
policy.style_src_attr :unsafe_inline
62+
policy.img_src :self, :data, :blob, *asset_hosts
63+
policy.font_src :self, :data, *asset_hosts
64+
policy.media_src :self, :blob, *asset_hosts
65+
policy.connect_src :self, *asset_hosts
66+
policy.frame_src :self, *preview_hosts
67+
policy.object_src :none
68+
policy.base_uri :self
69+
policy.form_action :self
70+
# An endpoint of your own that the browser POSTs a JSON report to
71+
# whenever it blocks something:
72+
#
73+
# policy.report_uri "/csp-violation-reports"
74+
end
75+
end
76+
77+
private
78+
79+
def asset_hosts
80+
host = ActionController::Base.asset_host
81+
return [] if host.blank?
82+
83+
hosts = if host.respond_to?(:call)
84+
[call_asset_host(host)]
85+
elsif host.include?("%d")
86+
# Rails shards these over four hosts.
87+
4.times.map { host % _1 }
88+
else
89+
[host]
90+
end
91+
hosts.filter_map { origin(_1) }.uniq
92+
end
93+
94+
def call_asset_host(host)
95+
arity = host.respond_to?(:arity) ? host.arity : host.method(:call).arity
96+
args = [ASSET_SOURCE]
97+
args << request if request && (arity > 1 || arity < 0)
98+
host.call(*args)
99+
end
100+
101+
# Modules can be pinned to a CDN, in which case the pin holds an absolute
102+
# URL instead of an asset name.
103+
def importmap_origins
104+
Alchemy.importmap.packages.values.filter_map { origin(_1.path) }.uniq
105+
end
106+
107+
# Admin stylesheets are usually asset names, but an absolute URL is
108+
# allowed here too.
109+
def admin_stylesheet_origins
110+
Alchemy.config.admin_stylesheets.filter_map { origin(_1) }.uniq
111+
end
112+
113+
# A CSP source is an origin, so anything with a host contributes one and
114+
# everything else (asset names, module specifiers) is same origin already.
115+
def origin(url)
116+
uri = URI.parse(url.to_s)
117+
return nil unless uri.host
118+
119+
port = ":#{uri.port}" if uri.port && uri.port != uri.default_port
120+
"#{uri.scheme ? "#{uri.scheme}://" : "//"}#{uri.host}#{port}"
121+
rescue URI::InvalidURIError => error
122+
raise InvalidSourceError, "Cannot build a Content Security Policy source from " \
123+
"#{url.inspect} (#{error.message}). It came from your asset host, your " \
124+
"admin stylesheets, an importmap pin or a preview host."
125+
end
126+
127+
# The page editor renders the frontend of the application in an iframe,
128+
# which is a different host whenever a preview host is configured.
129+
def preview_hosts
130+
preview = Alchemy.config.preview
131+
([preview.host] + preview.per_site_configs.map(&:host)).filter_map { origin(_1) }.uniq
132+
end
133+
end
134+
end
135+
end

lib/alchemy/configurations/main.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,18 @@ def user_class_name = "::#{raw_user_class}"
511511
#
512512
configuration :admin_components, AdminComponents
513513

514+
# === Admin Content Security Policy
515+
#
516+
# The class building the Content Security Policy Alchemy sends with its
517+
# own admin responses. Set to +nil+ to send no policy at all.
518+
#
519+
# It never replaces a policy the host application has configured, and it
520+
# only applies to Alchemy's own controllers.
521+
#
522+
# Alchemy.config.admin_content_security_policy = "MyApp::AdminContentSecurityPolicy"
523+
#
524+
option :admin_content_security_policy, :class, default: "Alchemy::Admin::ContentSecurityPolicy"
525+
514526
# === Publishable resolver
515527
#
516528
# The class used to resolve publication state for Publishable records

lib/alchemy/upgrader/eight_four.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ def notify_attachment_filetypes_default
4747
TODO
4848
end
4949

50+
def notify_admin_content_security_policy
51+
todo(<<~TODO.strip, "The admin now sends a Content Security Policy")
52+
If you have inline scripts or anything else in your admin that a CSP
53+
would block, subclass `Alchemy::Admin::ContentSecurityPolicy`, or set
54+
`config.admin_content_security_policy = nil` to turn it off.
55+
TODO
56+
end
57+
5058
# Element partials that render nested elements through the
5159
# +nested_elements+ association issue one database query per parent
5260
# element. Rendering through the block helper's +nested_elements+

lib/alchemy_cms.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
# Require globally used Alchemy mixins
2323
require_relative "alchemy/ability_helper"
24+
require_relative "alchemy/admin/content_security_policy"
2425
require_relative "alchemy/admin/locale"
2526
require_relative "alchemy/admin/timezone"
2627
require_relative "alchemy/admin/preview_time"

lib/generators/alchemy/install/templates/alchemy.rb.tt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,17 @@ Alchemy.configure do |config|
256256
# Additional stylesheets to be included in the Alchemy admin UI
257257
# config.admin_stylesheets.add("my_app/admin_extension")
258258

259+
# === Admin Content Security Policy
260+
#
261+
# The class building the Content Security Policy Alchemy sends with its own
262+
# admin responses. Set it to nil to send no policy at all.
263+
#
264+
# Alchemy never replaces a policy your application has configured itself.
265+
# Your asset host, your admin stylesheets and modules pinned to a CDN are
266+
# allowed automatically. Subclass it to allow anything else.
267+
#
268+
# config.admin_content_security_policy = <%= @default_config.raw_admin_content_security_policy.inspect %>
269+
259270
# Define page publish targets
260271
#
261272
# A publish target is a ActiveJob that gets performed

lib/tasks/alchemy/upgrade.rake

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ namespace :alchemy do
6161
task "run" => [
6262
"alchemy:upgrade:8.4:add_dragonfly_gem",
6363
"alchemy:upgrade:8.4:upgrade_nested_elements_rendering",
64-
"alchemy:upgrade:8.4:notify_attachment_filetypes_default"
64+
"alchemy:upgrade:8.4:notify_attachment_filetypes_default",
65+
"alchemy:upgrade:8.4:notify_admin_content_security_policy"
6566
]
6667

6768
desc "Add dragonfly gem to the Gemfile if the app uses the dragonfly storage adapter"
@@ -78,6 +79,11 @@ namespace :alchemy do
7879
task notify_attachment_filetypes_default: [:environment] do
7980
Alchemy::Upgrader["8.4"].notify_attachment_filetypes_default
8081
end
82+
83+
desc "Notify about the new admin Content Security Policy"
84+
task notify_admin_content_security_policy: [:environment] do
85+
Alchemy::Upgrader["8.4"].notify_admin_content_security_policy
86+
end
8187
end
8288
end
8389
end

spec/dummy/config/initializers/alchemy.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,15 @@
244244

245245
# The sizes for the preview size select in the page editor.
246246
# config.page_preview_sizes = [360, 640, 768, 1024, 1280, 1440]
247+
248+
# === Admin Content Security Policy
249+
#
250+
# The class building the Content Security Policy Alchemy sends with its own
251+
# admin responses. Set it to nil to send no policy at all.
252+
#
253+
# Alchemy never replaces a policy your application has configured itself.
254+
# Your asset host, your admin stylesheets and modules pinned to a CDN are
255+
# allowed automatically. Subclass it to allow anything else.
256+
#
257+
# config.admin_content_security_policy = "Alchemy::Admin::ContentSecurityPolicy"
247258
end

spec/lib/alchemy/upgrader/eight_four_spec.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@
7979
end
8080
end
8181

82+
describe "#notify_admin_content_security_policy" do
83+
subject { upgrader.notify_admin_content_security_policy }
84+
85+
it "adds a todo about the new policy" do
86+
expect(upgrader).to receive(:todo).with(kind_of(String), kind_of(String))
87+
subject
88+
end
89+
end
90+
8291
describe "#upgrade_nested_elements_rendering" do
8392
subject { upgrader.upgrade_nested_elements_rendering }
8493

0 commit comments

Comments
 (0)