summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-18 16:30:31 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-18 16:30:31 +0200
commit0c6783816e0a7a8acad922296d3eb8cc454fb981 (patch)
tree54c59e86afa188de6429ff0e1f07d1b6e4f36350
parent6b6e50909cc77de1797e88be5445c5b643b008a9 (diff)
Emit a report-only Content-Security-Policy with nonced inline scripts
- dark-mode restore now travels nonced, the admin constants likewise - AUTH_TOKEN deleted in favour of the csrf meta tag - new report collector at /csp_reports
-rw-r--r--app/controllers/csp_reports_controller.rb10
-rw-r--r--app/views/layouts/admin.html.erb5
-rw-r--r--app/views/layouts/application.html.erb10
-rw-r--r--config/initializers/content_security_policy.rb49
-rw-r--r--config/routes.rb2
-rw-r--r--public/javascripts/admin_interface.js6
-rw-r--r--test/controllers/csp_reports_controller_test.rb8
-rw-r--r--test/integration/csp_header_test.rb12
8 files changed, 67 insertions, 35 deletions
diff --git a/app/controllers/csp_reports_controller.rb b/app/controllers/csp_reports_controller.rb
new file mode 100644
index 0000000..08cbc98
--- /dev/null
+++ b/app/controllers/csp_reports_controller.rb
@@ -0,0 +1,10 @@
1class CspReportsController < ApplicationController
2 # Browsers POST application/csp-report with no CSRF token, no session.
3 skip_before_action :verify_authenticity_token
4
5 def create
6 report = request.body.read(8192)
7 Rails.logger.warn("CSP violation: #{report}") if report.present?
8 head :no_content
9 end
10end
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 0856a0f..e220beb 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -7,19 +7,18 @@
7 <%= csrf_meta_tags %> 7 <%= csrf_meta_tags %>
8 8
9 <title><%= "#{params[:controller]} | #{params[:action]}" %></title> 9 <title><%= "#{params[:controller]} | #{params[:action]}" %></title>
10 <%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? %>
11 <%= javascript_include_tag 'admin_bundle' %> 10 <%= javascript_include_tag 'admin_bundle' %>
12 <%= tinymce_assets %> 11 <%= tinymce_assets %>
13 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>"> 12 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>">
14 <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script> 13 <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script>
15 <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script> 14 <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script>
16 <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script> 15 <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script>
17 <script> 16 <%= javascript_tag nonce: true do %>
18 var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; 17 var ADMIN_SEARCH_URL = "<%= admin_search_path %>";
19 var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; 18 var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>";
20 var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; 19 var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>";
21 var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; 20 var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>";
22 </script> 21 <% end %>
23 </head> 22 </head>
24 23
25 <body> 24 <body>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index d7681af..ca867ab 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -18,12 +18,12 @@
18 <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %> 18 <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %>
19 <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %> 19 <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %>
20 20
21 <script> 21 <%= javascript_tag nonce: true do %>
22 (function() { document.addEventListener("DOMContentLoaded", function() { 22 document.addEventListener("DOMContentLoaded", function() {
23 if (localStorage.getItem('override-prefers-color-scheme', false)) 23 if (localStorage.getItem('override-prefers-color-scheme'))
24 document.getElementById("light-mode").checked = true; 24 document.getElementById("light-mode").checked = true;
25 }); })(); 25 });
26 </script> 26 <% end %>
27 27
28 </head> 28 </head>
29 29
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index d51d713..1569942 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -4,26 +4,29 @@
4# See the Securing Rails Applications Guide for more information: 4# See the Securing Rails Applications Guide for more information:
5# https://guides.rubyonrails.org/security.html#content-security-policy-header 5# https://guides.rubyonrails.org/security.html#content-security-policy-header
6 6
7# Rails.application.configure do 7Rails.application.configure do
8# config.content_security_policy do |policy| 8 config.content_security_policy do |policy|
9# policy.default_src :self, :https 9 policy.default_src :self
10# policy.font_src :self, :https, :data 10 policy.script_src :self
11# policy.img_src :self, :https, :data 11 policy.style_src :self, :unsafe_inline
12# policy.object_src :none 12 policy.img_src :self, :data
13# policy.script_src :self, :https 13 policy.font_src :self
14# policy.style_src :self, :https 14 policy.object_src :none
15# # Specify URI for violation reports 15 policy.frame_ancestors :none
16# # policy.report_uri "/csp-violation-report-endpoint" 16 policy.base_uri :self
17# end 17 policy.form_action :self
18# 18 policy.report_uri "/csp_reports"
19# # Generate session nonces for permitted importmap, inline scripts, and inline styles. 19 end
20# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 20
21# config.content_security_policy_nonce_directives = %w(script-src style-src) 21 # Per-request nonce; script-src only. style-src keeps unsafe_inline
22# 22 # deliberately: TinyMCE emits img[style] in body content and the
23# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` 23 # public layout carries style attributes -- CSS injection is a
24# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. 24 # low-yield channel, script-src is where the protection lives.
25# # config.content_security_policy_nonce_auto = true 25 config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
26# 26 config.content_security_policy_nonce_directives = %w[script-src]
27# # Report violations without enforcing the policy. 27
28# # config.content_security_policy_report_only = true 28 # Report-only: nothing blocks. Enforcement is a later, deliberate
29# end 29 # flip once the reports have mapped reality (admin inline scripts,
30 # legacy hotlinked images in old bodies, embeds).
31 config.content_security_policy_report_only = true
32end
diff --git a/config/routes.rb b/config/routes.rb
index f389326..3aa5c69 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,8 @@ Cccms::Application.routes.draw do
9 defaults: { page_path: ['home'] }, 9 defaults: { page_path: ['home'] },
10 constraints: { locale: /de|en/ } 10 constraints: { locale: /de|en/ }
11 11
12 post 'csp_reports' => 'csp_reports#create'
13
12 # All application routes are scoped under an optional two-letter locale 14 # All application routes are scoped under an optional two-letter locale
13 # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted 15 # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted
14 # for the default locale (:de) in generated URLs via default_url_options 16 # for the default locale (:de) in generated URLs via default_url_options
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index c0e2d9c..b7bdc10 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -82,10 +82,8 @@ $(document).ready(function () {
82 }); 82 });
83 83
84 $(document).ajaxSend(function(event, request, settings) { 84 $(document).ajaxSend(function(event, request, settings) {
85 if (typeof(AUTH_TOKEN) == "undefined") return; 85 var meta = document.querySelector("meta[name='csrf-token']");
86 // settings.data is a serialized string like "foo=bar&baz=boink" (or null) 86 if (meta) request.setRequestHeader("X-CSRF-Token", meta.content);
87 settings.data = settings.data || "";
88 settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN);
89 }); 87 });
90 88
91}); 89});
diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb
new file mode 100644
index 0000000..7dd8c9e
--- /dev/null
+++ b/test/controllers/csp_reports_controller_test.rb
@@ -0,0 +1,8 @@
1require 'test_helper'
2
3class CspReportsControllerTest < ActionController::TestCase
4 test "accepts anonymous reports without CSRF" do
5 post :create, body: '{"csp-report":{"violated-directive":"script-src"}}'
6 assert_response :no_content
7 end
8end
diff --git a/test/integration/csp_header_test.rb b/test/integration/csp_header_test.rb
new file mode 100644
index 0000000..73707ed
--- /dev/null
+++ b/test/integration/csp_header_test.rb
@@ -0,0 +1,12 @@
1require 'test_helper'
2
3class CspHeaderTest < ActionDispatch::IntegrationTest
4 test "public responses carry the report-only CSP header with a nonce" do
5 get "/"
6
7 header = response.headers["Content-Security-Policy-Report-Only"]
8 assert header.present?
9 assert_includes header, "script-src"
10 assert_includes header, "nonce-"
11 end
12end