diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-18 16:30:31 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-18 16:30:31 +0200 |
| commit | 0c6783816e0a7a8acad922296d3eb8cc454fb981 (patch) | |
| tree | 54c59e86afa188de6429ff0e1f07d1b6e4f36350 | |
| parent | 6b6e50909cc77de1797e88be5445c5b643b008a9 (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.rb | 10 | ||||
| -rw-r--r-- | app/views/layouts/admin.html.erb | 5 | ||||
| -rw-r--r-- | app/views/layouts/application.html.erb | 10 | ||||
| -rw-r--r-- | config/initializers/content_security_policy.rb | 49 | ||||
| -rw-r--r-- | config/routes.rb | 2 | ||||
| -rw-r--r-- | public/javascripts/admin_interface.js | 6 | ||||
| -rw-r--r-- | test/controllers/csp_reports_controller_test.rb | 8 | ||||
| -rw-r--r-- | test/integration/csp_header_test.rb | 12 |
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 @@ | |||
| 1 | class 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 | ||
| 10 | end | ||
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 | 7 | Rails.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 | ||
| 32 | end | ||
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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 8 | end | ||
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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 12 | end | ||
