Skip to content

Commit 6255c85

Browse files
committed
feat(csp): send a Content Security Policy with admin responses
Alchemy now sends a Content Security Policy with its own admin responses, so a mistake that renders content unescaped stays a display bug instead of becoming script execution in an editor's session. It is on by default, for new installations and for upgrading applications alike, because the admin surface Alchemy ships works under it entirely. The option is documented in the configuration template, so it reaches existing applications through alchemy:upgrade:config, and the upgrader raises a todo pointing at it for the rare application that does have inline scripts in admin views of its own. The policy is built by a configurable class. Alchemy fills in the host dependent sources from what it already knows: the asset host, the preview hosts the page editor loads into an iframe, admin stylesheets served from another host, and modules pinned to a CDN. Anything beyond that is a subclass away. It only applies to Alchemy's own controllers, never to host application controllers inheriting from the admin base class, and it is skipped entirely when the host has configured a policy of its own. The csp-nonce meta tag is added unconditionally because Turbo reads the nonce from it to authorize the style element it injects for the progress bar. It renders nothing when no policy is configured. (cherry picked from commit f0c2ff9)
1 parent aff639a commit 6255c85

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)