From a175c84879c56b7daa051db740b652cf23e63596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 2 Jul 2025 15:18:27 +0200 Subject: [PATCH] Move CSP to Rails --- Gemfile | 3 - Gemfile.lock | 3 - app/controllers/application_controller.rb | 1 + .../dynamic_content_security_policy.rb | 29 ++-- app/helpers/application_helper.rb | 6 + app/helpers/frontend_asset_helper.rb | 2 +- app/helpers/secure_headers_helper.rb | 6 +- app/views/layouts/_common_head.html.erb | 2 +- config/application.rb | 3 - .../initializers/content_security_policy.rb | 113 +++++++++++--- config/initializers/lookbook.rb | 45 ------ config/initializers/secure_headers.rb | 141 ------------------ .../recaptcha/request_controller.rb | 32 +++- .../views/recaptcha/request/perform.html.erb | 4 +- .../lib/open_project/recaptcha/engine.rb | 20 --- spec/support/shared/with_direct_uploads.rb | 20 ++- 16 files changed, 166 insertions(+), 264 deletions(-) rename lib/open_project/patches/secure_headers_turbo_aware_nonce.rb => app/controllers/concerns/dynamic_content_security_policy.rb (63%) delete mode 100644 config/initializers/secure_headers.rb diff --git a/Gemfile b/Gemfile index 4a642b3b4bf..0bd50ce9ffe 100644 --- a/Gemfile +++ b/Gemfile @@ -138,9 +138,6 @@ gem "rack-protection", "~> 3.2.0" # https://github.com/kickstarter/rack-attack gem "rack-attack", "~> 6.7.0" -# CSP headers -gem "secure_headers", "~> 7.1.0" - # Browser detection for incompatibility checks gem "browser", "~> 6.2.0" diff --git a/Gemfile.lock b/Gemfile.lock index ce65a0fc6fc..c14ea7025d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1167,7 +1167,6 @@ GEM nokogiri (>= 1.16.8) scimitar (2.11.0) rails (>= 7.0) - secure_headers (7.1.0) securerandom (0.4.1) selenium-devtools (0.138.0) selenium-webdriver (~> 4.2) @@ -1515,7 +1514,6 @@ DEPENDENCIES rubytree (~> 2.1.0) sanitize (~> 7.0.0) scimitar (~> 2.11) - secure_headers (~> 7.1.0) selenium-devtools selenium-webdriver (~> 4.20) semantic (~> 1.6.1) @@ -1948,7 +1946,6 @@ CHECKSUMS safety_net_attestation (0.4.0) sha256=96be2d74e7ed26453a51894913449bea0e072f44490021545ac2d1c38b0718ce sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984 scimitar (2.11.0) sha256=77cf779a843be7d572046acdcf0a1829bd3b1c33db993fa83faf7f1863d8c625 - secure_headers (7.1.0) sha256=6b1f9d5f9507af2948f4636452c41c09371927836396c2185438ffdf0a731124 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-devtools (0.138.0) sha256=596c08e114342dd89513bc3d18bbb2ae39e532864dbc25c09a48fb9922fc5b7b selenium-webdriver (4.34.0) sha256=ec7bb718cbe66fe2b247d8ca5e6ba26caed0976d76579d7cb2fadd8dae8b271e diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c5e84856c4c..8ddad78f7ee 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -56,6 +56,7 @@ class ApplicationController < ActionController::Base include OpenProjectErrorHelper include Security::DefaultUrlOptions include OpModalFlashable + include DynamicContentSecurityPolicy layout "base" diff --git a/lib/open_project/patches/secure_headers_turbo_aware_nonce.rb b/app/controllers/concerns/dynamic_content_security_policy.rb similarity index 63% rename from lib/open_project/patches/secure_headers_turbo_aware_nonce.rb rename to app/controllers/concerns/dynamic_content_security_policy.rb index 64571d7bcd8..4d9e015a0c9 100644 --- a/lib/open_project/patches/secure_headers_turbo_aware_nonce.rb +++ b/app/controllers/concerns/dynamic_content_security_policy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -25,18 +27,23 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -# -module OpenProject::Patches::SecureHeadersTurboAwareNonce - def content_security_policy_script_nonce(request) - if request.env["HTTP_TURBO_REFERRER"].present? - request.env[SecureHeaders::NONCE_KEY] ||= request.env["HTTP_X_TURBO_NONCE"] +module DynamicContentSecurityPolicy + ## + # Dynamically append sources to CSP directives + # This replaces the secure_headers named append functionality + def append_content_security_policy_directives(directives) + current_policy = current_content_security_policy + directives.each do |directive, source_values| + current_value = current_policy.send(directive) || current_policy.default_src + new_values = + if current_value == %w('none') # rubocop:disable Lint/PercentStringArray + source_values.compact.uniq + else + (current_value + source_values).compact.uniq + end + + request.content_security_policy.send(directive, *new_values) end - - super end end - -OpenProject::Patches.patch_gem_version "secure_headers", "7.1.0" do - SecureHeaders.singleton_class.prepend OpenProject::Patches::SecureHeadersTurboAwareNonce -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 38f86560e68..88dc6c9fb0e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -219,6 +219,12 @@ module ApplicationHelper end end + # Backward compatibility helper for secure_headers gem migration + # Rails built-in CSP equivalent of nonced_javascript_tag + def nonced_javascript_tag(**, &) + javascript_tag(nonce: true, **, &) + end + def to_path_param(path) path.to_s end diff --git a/app/helpers/frontend_asset_helper.rb b/app/helpers/frontend_asset_helper.rb index 38772224edd..b873ed0ea3a 100644 --- a/app/helpers/frontend_asset_helper.rb +++ b/app/helpers/frontend_asset_helper.rb @@ -66,7 +66,7 @@ module FrontendAssetHelper end def nonced_javascript_include_tag(path, **) - javascript_include_tag(path, nonce: content_security_policy_script_nonce, **) + javascript_include_tag(path, nonce: content_security_policy_nonce, **) end private diff --git a/app/helpers/secure_headers_helper.rb b/app/helpers/secure_headers_helper.rb index cebd0d6b9f2..826ce6626b8 100644 --- a/app/helpers/secure_headers_helper.rb +++ b/app/helpers/secure_headers_helper.rb @@ -29,10 +29,8 @@ module SecureHeadersHelper ## # Output a rails +csp_meta_tag+ compatible tag - # while we're still using the +secure_headers+ gem. + # using Rails built-in CSP functionality. def secure_header_csp_meta_tag - tag :meta, - name: "csp-nonce", - content: content_security_policy_script_nonce + csp_meta_tag end end diff --git a/app/views/layouts/_common_head.html.erb b/app/views/layouts/_common_head.html.erb index 57d37e7add5..ce5f79f98d1 100644 --- a/app/views/layouts/_common_head.html.erb +++ b/app/views/layouts/_common_head.html.erb @@ -31,7 +31,7 @@ <%= render "common/favicons" %> <%# Allow gon output when necessary %> -<%= include_gon(nonce: content_security_policy_script_nonce) %> +<%= include_gon(nonce: content_security_policy_nonce) %> <%# Include CLI assets (development) or prod build assets %> <%= include_frontend_assets %> diff --git a/config/application.rb b/config/application.rb index 8dfadd67d4d..67611fcb0ec 100644 --- a/config/application.rb +++ b/config/application.rb @@ -125,9 +125,6 @@ module OpenProject # http://stackoverflow.com/questions/4590229 config.middleware.use Rack::TempfileReaper - # Move secure_headers middleware to after the ShowExceptions - config.middleware.move_after ActionDispatch::ShowExceptions, SecureHeaders::Middleware - # Add lookbook preview paths when enabled if OpenProject::Configuration.lookbook_enabled? config.paths.add Primer::ViewComponents::Engine.root.join("app/components").to_s, eager_load: true diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b3076b38fe1..e3b844bc760 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,25 +1,100 @@ +# frozen_string_literal: true + # Be sure to restart your server when you modify this file. # Define an application-wide content security policy. # 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) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +Rails.application.config.after_initialize do + Rails.application.configure do + config.content_security_policy do |policy| + # Valid for assets + assets_src = ["'self'"] + asset_host = OpenProject::Configuration.rails_asset_host + assets_src << asset_host if asset_host.present? + + # Valid for iframes + frame_src = %w['self' https://player.vimeo.com https://www.youtube.com] # rubocop:disable Lint/PercentStringArray + frame_src << OpenProject::Configuration[:security_badge_url] + + # Default src + default_src = %w('self') # rubocop:disable Lint/PercentStringArray + + # Attachment uploaders + default_src += OpenProject::Configuration.remote_storage_hosts + + # Chargebee self-service + frame_src += [ + "https://js.chargebee.com/", + "#{OpenProject::Configuration.enterprise_chargebee_site}.chargebee.com" + ] + + default_src << "#{OpenProject::Configuration.enterprise_chargebee_site}.chargebee.com" + + # Allow requests to CLI in dev mode + connect_src = default_src + [OpenProject::Configuration.enterprise_trial_creation_host] + + # Rules for media (e.g. video sources) + media_src = default_src + media_src << asset_host if asset_host.present? + + if OpenProject::Configuration.appsignal_frontend_key + connect_src += ["https://appsignal-endpoint.net"] + end + + # Add proxy configuration for Angular CLI to csp + if FrontendAssetHelper.assets_proxied? + proxied = ["ws://#{Setting.host_name}", "http://#{Setting.host_name}", + FrontendAssetHelper.cli_proxy.sub("http", "ws"), FrontendAssetHelper.cli_proxy] + connect_src += proxied + assets_src += proxied + media_src += proxied + end + + # Allow to extend the script-src in specific situations + script_src = assets_src + %w(js.chargebee.com) + + # Allow unsafe-eval for rack-mini-profiler + if Rails.env.development? && ENV.fetch("OPENPROJECT_RACK_PROFILER_ENABLED", false) + script_src += %w('unsafe-eval') # rubocop:disable Lint/PercentStringArray + end + + # Allow ANDI bookmarklet to run in development mode + # https://www.ssa.gov/accessibility/andi/help/install.html + if Rails.env.development? + script_src += ["https://www.ssa.gov"] + assets_src += ["https://www.ssa.gov"] + end + + # Configure CSP directives + policy.default_src(*default_src) + policy.base_uri("'self'") + policy.font_src(*assets_src, "data:", "'self'") + policy.form_action(*default_src) + policy.frame_src(*frame_src, "'self'") + policy.frame_ancestors("'self'") + policy.img_src("*", "data:", "blob:") + policy.script_src(*script_src) + policy.script_src_attr("'none'") + policy.style_src(*assets_src, "'unsafe-inline'") + policy.object_src(OpenProject::Configuration[:security_badge_url]) + policy.connect_src(*connect_src) + policy.media_src(*media_src) + end + + # Generate session nonces for permitted importmap, inline scripts, and inline styles. + # This handles Turbo integration natively + config.content_security_policy_nonce_generator = lambda do |request| + # Use Turbo nonce if available (for Turbo navigation) + if request.env["HTTP_TURBO_REFERRER"].present? && request.env["HTTP_X_TURBO_NONCE"].present? + request.env["HTTP_X_TURBO_NONCE"] + else + # Generate a new nonce based on session + SecureRandom.base64(16) + end + end + + config.content_security_policy_nonce_directives = %w(script-src) + end +end diff --git a/config/initializers/lookbook.rb b/config/initializers/lookbook.rb index eb74d34c9aa..d6d167b170b 100644 --- a/config/initializers/lookbook.rb +++ b/config/initializers/lookbook.rb @@ -22,49 +22,4 @@ Rails.application.configure do # Show notes first, all other panels next config.lookbook.preview_inspector.drawer_panels = [:notes, "*"] config.lookbook.ui_theme = "blue" - - SecureHeaders::Configuration.named_append(:lookbook) do - proxied = - if FrontendAssetHelper.assets_proxied? - ["ws://#{Setting.host_name}", "http://#{Setting.host_name}", - FrontendAssetHelper.cli_proxy.sub("http", "ws"), FrontendAssetHelper.cli_proxy] - else - [] - end - - { - script_src: proxied + %w('unsafe-eval' 'unsafe-inline' 'self'), # rubocop:disable Lint/PercentStringArray - script_src_elem: proxied + %w('unsafe-eval' 'unsafe-inline' 'self'), # rubocop:disable Lint/PercentStringArray - style_src: proxied + %w('self' 'unsafe-inline'), # rubocop:disable Lint/PercentStringArray - style_src_attr: proxied + %w('self' 'unsafe-inline') # rubocop:disable Lint/PercentStringArray - } - end - - # rubocop:disable Lint/ConstantDefinitionInBlock - module LookbookCspExtender - extend ActiveSupport::Concern - - included do - before_action do - use_content_security_policy_named_append :lookbook - end - end - end - # rubocop:enable Lint/ConstantDefinitionInBlock - - Rails.application.reloader.to_prepare do - Lookbook.add_input_type(:octicon, "lookbook/previews/inputs/octicon") - - [ - Lookbook::ApplicationController, - Lookbook::PreviewController, - Lookbook::PreviewsController, - Lookbook::PageController, - Lookbook::PagesController, - Lookbook::InspectorController, - Lookbook::EmbedsController - ].each do |controller| - controller.include LookbookCspExtender - end - end end diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb deleted file mode 100644 index 6e0d364215f..00000000000 --- a/config/initializers/secure_headers.rb +++ /dev/null @@ -1,141 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -# rubocop:disable Lint/PercentStringArray -Rails.application.config.after_initialize do - SecureHeaders::Configuration.default do |config| - config.cookies = { - secure: true, - httponly: true - } - - # Let Rails ActionDispatch::SSL middleware handle the Strict-Transport-Security header - config.hsts = SecureHeaders::OPT_OUT - - config.x_frame_options = "SAMEORIGIN" - config.x_content_type_options = "nosniff" - config.x_xss_protection = "1; mode=block" - config.x_permitted_cross_domain_policies = "none" - config.referrer_policy = "origin-when-cross-origin" - - # Valid for assets - assets_src = ["'self'"] - asset_host = OpenProject::Configuration.rails_asset_host - assets_src << asset_host if asset_host.present? - - # Valid for iframes - frame_src = %w['self' https://player.vimeo.com https://www.youtube.com] - frame_src << OpenProject::Configuration[:security_badge_url] - - # Default src - default_src = %w('self') - - # Attachment uploaders - default_src += OpenProject::Configuration.remote_storage_hosts - - # Chargebee self-service - frame_src += [ - "https://js.chargebee.com/", - "#{OpenProject::Configuration.enterprise_chargebee_site}.chargebee.com" - ] - - default_src << "#{OpenProject::Configuration.enterprise_chargebee_site}.chargebee.com" - - # Allow requests to CLI in dev mode - connect_src = default_src + [OpenProject::Configuration.enterprise_trial_creation_host] - - # Rules for media (e.g. video sources) - media_src = default_src - media_src << asset_host if asset_host.present? - - if OpenProject::Configuration.appsignal_frontend_key - connect_src += ["https://appsignal-endpoint.net"] - end - - # Add proxy configuration for Angular CLI to csp - if FrontendAssetHelper.assets_proxied? - proxied = ["ws://#{Setting.host_name}", "http://#{Setting.host_name}", - FrontendAssetHelper.cli_proxy.sub("http", "ws"), FrontendAssetHelper.cli_proxy] - connect_src += proxied - assets_src += proxied - media_src += proxied - end - - # Allow to extend the script-src in specific situations - script_src = assets_src + %w(js.chargebee.com) - - # Allow unsafe-eval for rack-mini-profiler - if Rails.env.development? && ENV.fetch("OPENPROJECT_RACK_PROFILER_ENABLED", false) - script_src += %w('unsafe-eval') - end - - # Allow ANDI bookmarklet to run in development mode - # https://www.ssa.gov/accessibility/andi/help/install.html - if Rails.env.development? - script_src += ["https://www.ssa.gov"] - assets_src += ["https://www.ssa.gov"] - end - - config.csp = { - preserve_schemes: true, - # Don't append unsafe-inline in CSP as a fallback - disable_nonce_backwards_compatibility: true, - - # Fallback when no value is defined - default_src:, - # Allowed uri in tag - base_uri: %w('self'), - - # Allow fonts from self, asset host, or DATA uri - font_src: assets_src + %w(data: 'self'), - # Form targets can only be self - form_action: default_src, - # Allow iframe from vimeo (welcome video) - frame_src: frame_src + %w('self'), - frame_ancestors: %w('self'), - # Allow images from anywhere including data urls and blobs (used in resizing) - img_src: %w(* data: blob:), - # Allow scripts from self - script_src:, - script_src_attr: %w('none'), - # Allow unsafe-inline styles - style_src: assets_src + %w('unsafe-inline'), - # Allow object-src from Release API - object_src: [OpenProject::Configuration[:security_badge_url]], - - # Connect sources for CLI in dev mode - connect_src:, - - # Allow videos from self and from the asset proxy in dev mode. - media_src: - } - end -end -# rubocop:enable Lint/PercentStringArray diff --git a/modules/recaptcha/app/controllers/recaptcha/request_controller.rb b/modules/recaptcha/app/controllers/recaptcha/request_controller.rb index 320d60a2893..f8385b7a42b 100644 --- a/modules/recaptcha/app/controllers/recaptcha/request_controller.rb +++ b/modules/recaptcha/app/controllers/recaptcha/request_controller.rb @@ -30,11 +30,11 @@ module ::Recaptcha # Request verification form def perform if OpenProject::Recaptcha::Configuration.use_hcaptcha? - use_content_security_policy_named_append(:hcaptcha) + allow_captcha_service(:hcaptcha) elsif OpenProject::Recaptcha::Configuration.use_turnstile? - use_content_security_policy_named_append(:turnstile) + allow_captcha_service(:turnstile) elsif OpenProject::Recaptcha::Configuration.use_recaptcha? - use_content_security_policy_named_append(:recaptcha) + allow_captcha_service(:recaptcha) end end @@ -163,5 +163,31 @@ module ::Recaptcha def failure_stage_redirect redirect_to authentication_stage_failure_path :recaptcha end + + ## + # Add CAPTCHA service CSP rules + def allow_captcha_service(service_type) + case service_type.to_sym + when :recaptcha + append_content_security_policy_directives( + frame_src: %w[https://www.recaptcha.net/recaptcha/ https://www.gstatic.com/recaptcha/] + ) + when :hcaptcha + sources = %w[https://*.hcaptcha.com] + append_content_security_policy_directives( + frame_src: sources, + script_src: sources, + style_src: sources, + connect_src: sources + ) + when :turnstile + sources = %w[https://challenges.cloudflare.com] + append_content_security_policy_directives( + frame_src: sources, + style_src: sources, + connect_src: sources + ) + end + end end end diff --git a/modules/recaptcha/app/views/recaptcha/request/perform.html.erb b/modules/recaptcha/app/views/recaptcha/request/perform.html.erb index 8e7cf27be13..b2b970ccca0 100644 --- a/modules/recaptcha/app/views/recaptcha/request/perform.html.erb +++ b/modules/recaptcha/app/views/recaptcha/request/perform.html.erb @@ -6,7 +6,7 @@ <% input_name = "g-recaptcha-response" %> <%= recaptcha_tags( - nonce: content_security_policy_script_nonce, + nonce: content_security_policy_nonce, callback: "submitRecaptchaForm", site_key: recaptcha_settings["website_key"] ) %> @@ -29,7 +29,7 @@ <% end %> <% elsif recaptcha_settings['recaptcha_type'] == ::OpenProject::Recaptcha::TYPE_V3 %> <%= recaptcha_v3 action: "login", - nonce: content_security_policy_script_nonce, + nonce: content_security_policy_nonce, callback: "submitRecaptchaForm", site_key: recaptcha_settings["website_key"] %> diff --git a/modules/recaptcha/lib/open_project/recaptcha/engine.rb b/modules/recaptcha/lib/open_project/recaptcha/engine.rb index 023a9ba73b1..94a826e2a29 100644 --- a/modules/recaptcha/lib/open_project/recaptcha/engine.rb +++ b/modules/recaptcha/lib/open_project/recaptcha/engine.rb @@ -28,26 +28,6 @@ module OpenProject::Recaptcha end config.after_initialize do - SecureHeaders::Configuration.named_append(:recaptcha) do - { - frame_src: %w[https://www.recaptcha.net/recaptcha/ https://www.gstatic.com/recaptcha/] - } - end - - SecureHeaders::Configuration.named_append(:hcaptcha) do - value = %w(https://*.hcaptcha.com) - keys = %i(frame_src script_src style_src connect_src) - - keys.index_with value - end - - SecureHeaders::Configuration.named_append(:turnstile) do - value = %w(https://challenges.cloudflare.com) - keys = %i(frame_src style_src connect_src) - - keys.index_with value - end - OpenProject::Authentication::Stage.register( :recaptcha, nil, diff --git a/spec/support/shared/with_direct_uploads.rb b/spec/support/shared/with_direct_uploads.rb index 523d80cc6b7..c82a1288070 100644 --- a/spec/support/shared/with_direct_uploads.rb +++ b/spec/support/shared/with_direct_uploads.rb @@ -65,19 +65,23 @@ class WithDirectUploads def around(example) example.metadata[:javascript_driver] = example.metadata[:driver] = :chrome_billy - csp_config = SecureHeaders::Configuration.instance_variable_get(:@default_config).csp + # Temporarily modify CSP for direct uploads testing + original_csp = Rails.application.config.content_security_policy - connect_src = csp_config[:connect_src].dup - form_action = csp_config[:form_action].dup + Rails.application.config.content_security_policy do |policy| + # Copy existing policy and add test bucket domain + original_csp.call(policy) if original_csp + + # Add test bucket to connect_src and form_action for direct uploads + policy.connect_src(*(policy.instance_variable_get(:@directives)[:connect_src] || []), "test-bucket.s3.amazonaws.com") + policy.form_action(*(policy.instance_variable_get(:@directives)[:form_action] || []), "test-bucket.s3.amazonaws.com") + end begin - csp_config[:connect_src] << "test-bucket.s3.amazonaws.com" - csp_config[:form_action] << "test-bucket.s3.amazonaws.com" - example.run ensure - csp_config[:connect_src] = connect_src - csp_config[:form_action] = form_action + # Restore original CSP configuration + Rails.application.config.content_security_policy = original_csp end end