From 0c6783816e0a7a8acad922296d3eb8cc454fb981 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 18 Jul 2026 16:30:31 +0200 Subject: 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 --- app/controllers/csp_reports_controller.rb | 10 +++++ app/views/layouts/admin.html.erb | 5 +-- app/views/layouts/application.html.erb | 10 ++--- config/initializers/content_security_policy.rb | 49 +++++++++++++------------ config/routes.rb | 2 + public/javascripts/admin_interface.js | 6 +-- test/controllers/csp_reports_controller_test.rb | 8 ++++ test/integration/csp_header_test.rb | 12 ++++++ 8 files changed, 67 insertions(+), 35 deletions(-) create mode 100644 app/controllers/csp_reports_controller.rb create mode 100644 test/controllers/csp_reports_controller_test.rb create mode 100644 test/integration/csp_header_test.rb 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 @@ +class CspReportsController < ApplicationController + # Browsers POST application/csp-report with no CSRF token, no session. + skip_before_action :verify_authenticity_token + + def create + report = request.body.read(8192) + Rails.logger.warn("CSP violation: #{report}") if report.present? + head :no_content + end +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 @@ <%= csrf_meta_tags %> <%= "#{params[:controller]} | #{params[:action]}" %> - <%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? %> <%= javascript_include_tag 'admin_bundle' %> <%= tinymce_assets %> - + <% end %> 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 @@ <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %> <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %> - + }); + <% end %> 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 @@ # See the Securing Rails Applications Guide for more information: # https://guides.rubyonrails.org/security.html#content-security-policy-header -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) -# -# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` -# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. -# # config.content_security_policy_nonce_auto = true -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self + policy.script_src :self + policy.style_src :self, :unsafe_inline + policy.img_src :self, :data + policy.font_src :self + policy.object_src :none + policy.frame_ancestors :none + policy.base_uri :self + policy.form_action :self + policy.report_uri "/csp_reports" + end + + # Per-request nonce; script-src only. style-src keeps unsafe_inline + # deliberately: TinyMCE emits img[style] in body content and the + # public layout carries style attributes -- CSS injection is a + # low-yield channel, script-src is where the protection lives. + config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) } + config.content_security_policy_nonce_directives = %w[script-src] + + # Report-only: nothing blocks. Enforcement is a later, deliberate + # flip once the reports have mapped reality (admin inline scripts, + # legacy hotlinked images in old bodies, embeds). + config.content_security_policy_report_only = true +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 defaults: { page_path: ['home'] }, constraints: { locale: /de|en/ } + post 'csp_reports' => 'csp_reports#create' + # All application routes are scoped under an optional two-letter locale # prefix: /de/... and /en/... Both forms are valid; the prefix is omitted # 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 () { }); $(document).ajaxSend(function(event, request, settings) { - if (typeof(AUTH_TOKEN) == "undefined") return; - // settings.data is a serialized string like "foo=bar&baz=boink" (or null) - settings.data = settings.data || ""; - settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN); + var meta = document.querySelector("meta[name='csrf-token']"); + if (meta) request.setRequestHeader("X-CSRF-Token", meta.content); }); }); 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 @@ +require 'test_helper' + +class CspReportsControllerTest < ActionController::TestCase + test "accepts anonymous reports without CSRF" do + post :create, body: '{"csp-report":{"violated-directive":"script-src"}}' + assert_response :no_content + end +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 @@ +require 'test_helper' + +class CspHeaderTest < ActionDispatch::IntegrationTest + test "public responses carry the report-only CSP header with a nonce" do + get "/" + + header = response.headers["Content-Security-Policy-Report-Only"] + assert header.present? + assert_includes header, "script-src" + assert_includes header, "nonce-" + end +end -- cgit v1.3