From ba092014b715729a6ded96f0dbfd54e69335e535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Tue, 2 Dec 2025 12:19:58 +0100 Subject: [PATCH 01/15] Load CSP from a config file. Co-Authored-By: Claude --- .dockerignore | 1 + .gitignore | 1 + config/application.rb | 4 ++ config/examples/content_security_policy.yml | 33 ++++++++++ .../initializers/content_security_policy.rb | 55 ++++++++-------- config/initializers/inflections.rb | 1 + .../lib/developer_portal/engine.rb | 7 +++ .../middleware/developer_portal_csp.rb | 56 +++++++++++++++++ lib/three_scale/content_security_policy.rb | 63 +++++++++++++++++++ 9 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 config/examples/content_security_policy.yml create mode 100644 lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb create mode 100644 lib/three_scale/content_security_policy.rb diff --git a/.dockerignore b/.dockerignore index 323f6bdc34..8556c627a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,6 +32,7 @@ config/backend_redis.yml config/cache_store.yml config/core.yml config/cors.yml +/config/content_security_policy.yml config/currencies.yml config/database.yml config/domain_substitution.yml diff --git a/.gitignore b/.gitignore index 1b5b9cbb02..24c0f0fe43 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ C:\\nppdf32Log\\debuglog.txt /config/cache_store.yml /config/core.yml /config/cors.yml +/config/content_security_policy.yml /config/currencies.yml /config/database.yml /config/domain_substitution.yml diff --git a/config/application.rb b/config/application.rb index c103d927eb..5a01ab8570 100644 --- a/config/application.rb +++ b/config/application.rb @@ -256,6 +256,10 @@ def cache_store_config config.three_scale.cors.enabled = false config.three_scale.cors.merge!(try_config_for(:cors) || {}) + config.three_scale.content_security_policy = ActiveSupport::OrderedOptions.new + config.three_scale.content_security_policy.enabled = false + config.three_scale.content_security_policy.merge!(try_config_for(:content_security_policy) || {}) + three_scale = config_for(:settings) three_scale[:error_reporting_stages] = three_scale[:error_reporting_stages].to_s.split(/\W+/) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml new file mode 100644 index 0000000000..0ea03d34e1 --- /dev/null +++ b/config/examples/content_security_policy.yml @@ -0,0 +1,33 @@ +base: &default + enabled: true + report_only: false + + # Admin portal policy - restrictive but allows unsafe-inline/eval for existing code + # Dynamically includes RAILS_ASSET_HOST for CDN assets if configured + admin_portal_policy: + default_src: ["'self'"] + script_src: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>"] + style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>"] + font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>"] + img_src: ["'self'", "data:", "blob:", "https:"] + connect_src: ["'self'"] + frame_src: ["'self'"] + frame_ancestors: ["'none'"] + object_src: ["'none'"] + base_uri: ["'self'"] + + # Developer portal policy - permissive defaults for customization + developer_portal_policy: + default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'" ] + +development: + <<: *default + enabled: true + +test: + <<: *default + enabled: true + +production: + <<: *default + enabled: true diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 601f89e6d4..d9cfbefc20 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,31 +1,32 @@ -# Be sure to restart your server when you modify this file. +# frozen_string_literal: true -# 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 +# Configure Content Security Policy headers +# See: https://guides.rubyonrails.org/security.html#content-security-policy-header -Rails.application.config.to_prepare do - Rails.application.config.content_security_policy do |policy| - policy.default_src '*', :data, :mediastream, :blob, :filesystem, :ws, :wss, :unsafe_eval, :unsafe_inline +require_dependency 'three_scale/content_security_policy' + +if ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? + # Apply configurable CSP from YAML + Rails.application.configure do + # Set report-only mode if configured + config.content_security_policy_report_only = true if ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? end -end -# 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 + # Apply global CSP policy from configuration + Rails.application.config.to_prepare do + policy_config = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + + if policy_config.present? + Rails.application.config.content_security_policy do |policy| + ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config(policy, policy_config) + end + end + end +else + # Fallback to permissive policy when config is disabled + Rails.application.config.to_prepare do + Rails.application.config.content_security_policy do |policy| + policy.default_src '*', :data, :mediastream, :blob, :filesystem, :ws, :wss, :unsafe_eval, :unsafe_inline + end + end +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index fffced996a..3b62886a83 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -23,6 +23,7 @@ inflect.acronym 'GitHub' inflect.acronym 'SSO' inflect.acronym 'OIDC' # OpenID Connect + inflect.acronym 'CSP' # Content Security Policy end # These inflection rules are supported but not enabled by default: diff --git a/lib/developer_portal/lib/developer_portal/engine.rb b/lib/developer_portal/lib/developer_portal/engine.rb index 34999e5847..ea439f0663 100644 --- a/lib/developer_portal/lib/developer_portal/engine.rb +++ b/lib/developer_portal/lib/developer_portal/engine.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require 'three_scale/middleware/developer_portal_csp' + module DeveloperPortal class Engine < ::Rails::Engine isolate_namespace DeveloperPortal @@ -5,6 +9,9 @@ class Engine < ::Rails::Engine config.autoload_paths += %W(#{config.root.join('lib')}) config.paths.add 'lib', eager_load: true + # Apply Developer Portal specific CSP policy + config.middleware.use ThreeScale::Middleware::DeveloperPortalCSP + initializer :assets do |config| Rails.application.config.assets.precompile += %w{ stats.css } Rails.application.config.assets.precompile += %w{ stats.js } diff --git a/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb b/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb new file mode 100644 index 0000000000..c5e107d9db --- /dev/null +++ b/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ThreeScale + module Middleware + class DeveloperPortalCSP + def initialize(app) + @app = app + + # Pre-compute the CSP header once at startup since we don't use nonces or dynamic sources + @csp_header_name, @csp_header_value = compute_csp_header + end + + def call(env) + request = ActionDispatch::Request.new(env) + + # We want to apply CSP only for HTML requests. However, we can't just return + # because Rails will add global CSP policy (admin portal policy) to the response + # if we don't do anything. We disable CSP for this request to prevent Rails middleware + # to interfere. + unless request.format.html? + request.content_security_policy = false + return @app.call(env) + end + + status, headers, _body = response = @app.call(env) + + # Don't apply CSP to 304 responses to avoid cache issues + return response if status == 304 + + # Only apply if we have a pre-computed CSP header + headers[@csp_header_name] = @csp_header_value if @csp_header_value + + response + end + + private + + def compute_csp_header + # Only compute if enabled and there's a policy configured + policy_config = ThreeScale::ContentSecurityPolicy::DeveloperPortal.policy_config + return [nil, nil] unless ThreeScale::ContentSecurityPolicy::DeveloperPortal.enabled? && policy_config.present? + + # Build the policy once at initialization + policy = ThreeScale::ContentSecurityPolicy::DeveloperPortal.build_policy(policy_config) + header_name = if ThreeScale::ContentSecurityPolicy::DeveloperPortal.report_only? + ActionDispatch::Constants::CONTENT_SECURITY_POLICY_REPORT_ONLY + else + ActionDispatch::Constants::CONTENT_SECURITY_POLICY + end + header_value = policy.build + + [header_name, header_value] + end + end + end +end diff --git a/lib/three_scale/content_security_policy.rb b/lib/three_scale/content_security_policy.rb new file mode 100644 index 0000000000..99fbbadfd8 --- /dev/null +++ b/lib/three_scale/content_security_policy.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module ThreeScale + module ContentSecurityPolicy + class Base + class << self + def config + @config ||= Rails.configuration.three_scale.content_security_policy + end + + def enabled? + config&.enabled == true + end + + def policy_config + raise NoMethodError, "#{__method__} not implemented in #{self.class}" + end + + def report_only? + config&.report_only == true + end + + # Builds an ActionDispatch::ContentSecurityPolicy object from a policy configuration hash + def build_policy(policy_config) + ActionDispatch::ContentSecurityPolicy.new do |policy| + add_policy_config(policy, policy_config) + end + end + + # Applies a policy configuration hash to an existing policy object + def add_policy_config(policy, policy_config) + policy_config.each do |directive, values| + method_name = directive.to_s + next unless policy.respond_to?(method_name) + + # Handle directives with sources (arrays) vs boolean directives + if values.is_a?(Array) + policy.public_send(method_name, *values) + else + policy.public_send(method_name, values) + end + end + end + end + end + + class AdminPortal < Base + class << self + def policy_config + config&.admin_portal_policy&.to_h || {} + end + end + end + + class DeveloperPortal < Base + class << self + def policy_config + config&.developer_portal_policy&.to_h || {} + end + end + end + end +end From 0a95ae6a3688d83cdaa94c2a8ce9cf2248874cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Tue, 2 Dec 2025 12:20:10 +0100 Subject: [PATCH 02/15] Add/Fix tests Co-Authored-By: Claude --- features/step_definitions/asset_host_steps.rb | 24 ++++ .../content_security_policy_test.rb | 78 ++++++++++++ test/integration/secure_headers_test.rb | 1 - .../content_security_policy_test.rb | 116 ++++++++++++++++++ 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 test/integration/developer_portal/content_security_policy_test.rb create mode 100644 test/unit/three_scale/content_security_policy_test.rb diff --git a/features/step_definitions/asset_host_steps.rb b/features/step_definitions/asset_host_steps.rb index 6e48899803..24ca490bcc 100644 --- a/features/step_definitions/asset_host_steps.rb +++ b/features/step_definitions/asset_host_steps.rb @@ -3,12 +3,36 @@ Given /^the asset host is unset$/ do Rails.configuration.three_scale.asset_host = nil Rails.configuration.asset_host = nil + + # Reconfigure CSP with original policy (without CDN URL) + policy_config = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + Rails.application.config.content_security_policy do |policy| + ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config(policy, policy_config) + Rails.application.instance_variable_get(:@app_env_config)&.[]=('action_dispatch.content_security_policy', policy) + end end Given /^the asset host is set to "(.*)"$/ do |asset_host| cdn_url = "#{asset_host}:#{Capybara.current_session.server.port}" Rails.configuration.three_scale.asset_host = cdn_url Rails.configuration.asset_host = cdn_url + + # Get original policy and add CDN URL to CSP directives + original_policy = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + policy_with_cdn = original_policy.deep_dup + + # Append CDN URL to script_src, style_src, and font_src + policy_with_cdn[:script_src] = (policy_with_cdn[:script_src] || []) + [cdn_url] + policy_with_cdn[:style_src] = (policy_with_cdn[:style_src] || []) + [cdn_url] + policy_with_cdn[:font_src] = (policy_with_cdn[:font_src] || []) + [cdn_url] + + # Reconfigure CSP with CDN URL included + # Rails set the CSP configuration just once during initialization, + # This hack is needed to make it change between tests + Rails.application.config.content_security_policy do |policy| + ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config(policy, policy_with_cdn) + Rails.application.instance_variable_get(:@app_env_config)&.[]=('action_dispatch.content_security_policy', policy) + end end Then /^(?:javascript\s)?assets should be loaded from the asset host$/ do diff --git a/test/integration/developer_portal/content_security_policy_test.rb b/test/integration/developer_portal/content_security_policy_test.rb new file mode 100644 index 0000000000..c90d1fc3d9 --- /dev/null +++ b/test/integration/developer_portal/content_security_policy_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DeveloperPortal::ContentSecurityPolicyTest < ActionDispatch::IntegrationTest + + class TestController < ApplicationController + def html + render html: 'Test'.html_safe + end + + def json + render json: { test: true } + end + + def not_modified + head :not_modified + end + end + + def with_test_routes + DeveloperPortal::Engine.routes.draw do + get '/test/csp/html' => 'content_security_policy_test/test#html' + get '/test/csp/json' => 'content_security_policy_test/test#json' + get '/test/csp/304' => 'content_security_policy_test/test#not_modified' + end + yield + ensure + Rails.application.routes_reloader.reload! + end + + def setup + @provider = FactoryBot.create(:provider_account) + @buyer = FactoryBot.create(:buyer_account, provider_account: @provider) + host! @provider.internal_domain + end + + test 'includes configured directives from YAML' do + with_test_routes do + get '/test/csp/html' + + assert_response :success + csp_header = response.headers['Content-Security-Policy'] + + # Verify it contains the permissive default_src directive from developer_portal_policy + assert_includes csp_header, "default-src" + assert_includes csp_header, "*" + assert_includes csp_header, "'unsafe-eval'" + assert_includes csp_header, "'unsafe-inline'" + end + end + + test 'does not apply CSP headers to non-HTML responses' do + with_test_routes do + get '/test/csp/json', params: { format: :json } + + assert_response :success + # JSON responses should not have CSP headers + assert_nil response.headers['Content-Security-Policy'] + assert_nil response.headers['Content-Security-Policy-Report-Only'] + end + end + + test 'middleware handles 304 Not Modified responses correctly' do + # The middleware has special handling for 304 responses to avoid + # nonce mismatches with cached content. This test verifies the middleware + # returns early for 304 responses and does not add CSP headers. + + with_test_routes do + get '/test/csp/304' + + assert_response :not_modified + # CSP middleware should not add headers to 304 responses + assert_nil response.headers['Content-Security-Policy'] + assert_nil response.headers['Content-Security-Policy-Report-Only'] + end + end +end diff --git a/test/integration/secure_headers_test.rb b/test/integration/secure_headers_test.rb index 5789002bfc..394e9f2f5e 100644 --- a/test/integration/secure_headers_test.rb +++ b/test/integration/secure_headers_test.rb @@ -15,7 +15,6 @@ def setup assert_equal 'DENY', response.headers['X-Frame-Options'] assert_equal 'nosniff', response.headers['X-Content-Type-Options'] assert_equal '1; mode=block', response.headers['X-XSS-Protection'] - assert_equal "default-src * data: mediastream: blob: filesystem: ws: wss: 'unsafe-eval' 'unsafe-inline'", response.headers['Content-Security-Policy'] end test 'do not add non used secure headers in the response' do diff --git a/test/unit/three_scale/content_security_policy_test.rb b/test/unit/three_scale/content_security_policy_test.rb new file mode 100644 index 0000000000..5373186589 --- /dev/null +++ b/test/unit/three_scale/content_security_policy_test.rb @@ -0,0 +1,116 @@ +require 'test_helper' + +class ThreeScale::ContentSecurityPolicy::AdminPortalTest < ActiveSupport::TestCase + test 'config returns Rails configuration for CSP' do + config = ThreeScale::ContentSecurityPolicy::AdminPortal.config + + assert_not_nil config + assert_kind_of ActiveSupport::OrderedOptions, config + end + + test 'enabled? returns true in test environment' do + assert_equal true, ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? + end + + test 'policy_config returns hash of CSP directives from YAML' do + policy_hash = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + + assert_kind_of Hash, policy_hash + assert policy_hash.present? + + # Verify it contains restrictive directives + assert policy_hash.key?(:default_src) + assert_includes policy_hash[:default_src], "'self'" + assert policy_hash.key?(:script_src) + assert_includes policy_hash[:script_src], "'self'" + end + + test 'report_only? returns false from YAML config' do + assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? + end + + test 'policy_config returns empty hash when config.admin_portal_policy is nil' do + ThreeScale::ContentSecurityPolicy::AdminPortal.config.stubs(:admin_portal_policy).returns(nil) + policy_hash = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + + assert_equal({}, policy_hash) + end +end + +class ThreeScale::ContentSecurityPolicy::DeveloperPortalTest < ActiveSupport::TestCase + test 'config returns Rails configuration for CSP' do + config = ThreeScale::ContentSecurityPolicy::DeveloperPortal.config + + assert_not_nil config + assert_kind_of ActiveSupport::OrderedOptions, config + end + + test 'enabled? returns true in test environment' do + assert_equal true, ThreeScale::ContentSecurityPolicy::DeveloperPortal.enabled? + end + + test 'policy_config returns hash of CSP directives from YAML' do + policy_hash = ThreeScale::ContentSecurityPolicy::DeveloperPortal.policy_config + + assert_kind_of Hash, policy_hash + assert policy_hash.present? + + # Verify it contains the permissive default_src directive + assert policy_hash.key?(:default_src) + assert_includes policy_hash[:default_src], "*" + assert_includes policy_hash[:default_src], "'unsafe-eval'" + assert_includes policy_hash[:default_src], "'unsafe-inline'" + end + + test 'report_only? returns false from YAML config' do + assert_equal false, ThreeScale::ContentSecurityPolicy::DeveloperPortal.report_only? + end + + test 'policy_config returns empty hash when config.developer_portal_policy is nil' do + ThreeScale::ContentSecurityPolicy::DeveloperPortal.config.stubs(:developer_portal_policy).returns(nil) + policy_hash = ThreeScale::ContentSecurityPolicy::DeveloperPortal.policy_config + + assert_equal({}, policy_hash) + end +end + +class ThreeScale::ContentSecurityPolicyWithoutYAMLTest < ActiveSupport::TestCase + test 'AdminPortal enabled? returns false when config is missing' do + empty_config = ActiveSupport::OrderedOptions.new + ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(empty_config) + + assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? + end + + test 'AdminPortal policy_config returns empty hash when config is missing' do + empty_config = ActiveSupport::OrderedOptions.new + ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(empty_config) + + policy_hash = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + + assert_equal({}, policy_hash) + end + + test 'AdminPortal report_only? returns false when config is missing' do + empty_config = ActiveSupport::OrderedOptions.new + ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(empty_config) + + assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? + end + + test 'AdminPortal enabled? handles nil config values gracefully' do + config = ActiveSupport::OrderedOptions.new + config.enabled = nil + ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(config) + + assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? + end + + test 'AdminPortal report_only? handles nil config values gracefully' do + config = ActiveSupport::OrderedOptions.new + config.report_only = nil + ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(config) + + assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? + end +end From 04a1f9619b861fc0044846dd12c1c6df4e7385b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Thu, 11 Dec 2025 13:53:35 +0100 Subject: [PATCH 03/15] Simplify initializer --- .../initializers/content_security_policy.rb | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index d9cfbefc20..9c1e630948 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -3,29 +3,22 @@ # Configure Content Security Policy headers # See: https://guides.rubyonrails.org/security.html#content-security-policy-header -require_dependency 'three_scale/content_security_policy' +require 'three_scale/content_security_policy' -if ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? - # Apply configurable CSP from YAML - Rails.application.configure do - # Set report-only mode if configured - config.content_security_policy_report_only = true if ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? - end - - # Apply global CSP policy from configuration - Rails.application.config.to_prepare do +Rails.application.configure do + if ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? policy_config = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config if policy_config.present? - Rails.application.config.content_security_policy do |policy| + config.content_security_policy do |policy| ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config(policy, policy_config) end end - end -else - # Fallback to permissive policy when config is disabled - Rails.application.config.to_prepare do - Rails.application.config.content_security_policy do |policy| + + # Set report-only mode if configured + config.content_security_policy_report_only = true if ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? + else + config.content_security_policy do |policy| policy.default_src '*', :data, :mediastream, :blob, :filesystem, :ws, :wss, :unsafe_eval, :unsafe_inline end end From a0de8cd4594e8fb6a3625ecc2a81d9f0f1a887a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 12 Dec 2025 10:54:04 +0100 Subject: [PATCH 04/15] Separate `report_only` configs --- config/examples/content_security_policy.yml | 6 ++---- lib/three_scale/content_security_policy.rb | 10 +++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml index 0ea03d34e1..c98174d5f4 100644 --- a/config/examples/content_security_policy.yml +++ b/config/examples/content_security_policy.yml @@ -1,6 +1,5 @@ base: &default enabled: true - report_only: false # Admin portal policy - restrictive but allows unsafe-inline/eval for existing code # Dynamically includes RAILS_ASSET_HOST for CDN assets if configured @@ -15,19 +14,18 @@ base: &default frame_ancestors: ["'none'"] object_src: ["'none'"] base_uri: ["'self'"] + admin_portal_report_only: false # Developer portal policy - permissive defaults for customization developer_portal_policy: default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'" ] + developer_portal_report_only: false development: <<: *default - enabled: true test: <<: *default - enabled: true production: <<: *default - enabled: true diff --git a/lib/three_scale/content_security_policy.rb b/lib/three_scale/content_security_policy.rb index 99fbbadfd8..a19351a26e 100644 --- a/lib/three_scale/content_security_policy.rb +++ b/lib/three_scale/content_security_policy.rb @@ -17,7 +17,7 @@ def policy_config end def report_only? - config&.report_only == true + raise NoMethodError, "#{__method__} not implemented in #{self.class}" end # Builds an ActionDispatch::ContentSecurityPolicy object from a policy configuration hash @@ -49,6 +49,10 @@ class << self def policy_config config&.admin_portal_policy&.to_h || {} end + + def report_only? + config&.admin_portal_report_only == true + end end end @@ -57,6 +61,10 @@ class << self def policy_config config&.developer_portal_policy&.to_h || {} end + + def report_only? + config&.developer_portal_report_only == true + end end end end From e3bae5f27a904171ea508b87f1f1568a9665ce88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 12 Dec 2025 10:54:14 +0100 Subject: [PATCH 05/15] Fix test --- test/unit/three_scale/content_security_policy_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/three_scale/content_security_policy_test.rb b/test/unit/three_scale/content_security_policy_test.rb index 5373186589..aab5fc2677 100644 --- a/test/unit/three_scale/content_security_policy_test.rb +++ b/test/unit/three_scale/content_security_policy_test.rb @@ -108,7 +108,7 @@ class ThreeScale::ContentSecurityPolicyWithoutYAMLTest < ActiveSupport::TestCase test 'AdminPortal report_only? handles nil config values gracefully' do config = ActiveSupport::OrderedOptions.new - config.report_only = nil + config.admin_portal_report_only = nil ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(config) assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? From 66ad3c9016c5d2fb9d3c752be2f7b361c8ba302f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 12 Dec 2025 10:54:33 +0100 Subject: [PATCH 06/15] Add new unit test for the dev portal middleware --- .../middleware/developer_portal_csp_test.rb | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 test/unit/three_scale/middleware/developer_portal_csp_test.rb diff --git a/test/unit/three_scale/middleware/developer_portal_csp_test.rb b/test/unit/three_scale/middleware/developer_portal_csp_test.rb new file mode 100644 index 0000000000..6cce898765 --- /dev/null +++ b/test/unit/three_scale/middleware/developer_portal_csp_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ThreeScale::Middleware::DeveloperPortalCSPTest < ActiveSupport::TestCase + + test 'initializes with CSP header when enabled with normal mode' do + stub_csp_config(enabled: true, report_only: false, policy: default_policy) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_name middleware, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" + end + + test 'initializes with CSP report-only header when enabled with report-only mode' do + stub_csp_config(enabled: true, report_only: true, policy: default_policy) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_name middleware, 'Content-Security-Policy-Report-Only' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" + end + + test 'initializes with no header when CSP is disabled' do + stub_csp_config(enabled: false, report_only: false, policy: default_policy) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_name middleware, nil + assert_header_value middleware, nil + end + + test 'initializes with no header when policy_config is nil' do + stub_csp_config(enabled: true, report_only: false, policy: nil) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_name middleware, nil + assert_header_value middleware, nil + end + + test 'initializes with no header when policy_config is empty' do + stub_csp_config(enabled: true, report_only: false, policy: {}) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_name middleware, nil + assert_header_value middleware, nil + end + + test 'initializes with custom policy directives' do + custom_policy = { + default_src: ["'self'"], + script_src: ["'self'", "'unsafe-inline'"], + style_src: ["'self'", "https://cdn.example.com"] + } + stub_csp_config(enabled: true, report_only: false, policy: custom_policy) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_name middleware, 'Content-Security-Policy' + assert_header_value_includes middleware, "default-src 'self'" + assert_header_value_includes middleware, "script-src 'self' 'unsafe-inline'" + assert_header_value_includes middleware, "style-src 'self' https://cdn.example.com" + end + + test 'initializes with permissive policy including data URIs and websockets' do + permissive_policy = { + default_src: ["*", "data:", "blob:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'"] + } + stub_csp_config(enabled: true, report_only: false, policy: permissive_policy) + + middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) + + assert_header_value_includes middleware, "default-src *" + assert_header_value_includes middleware, "data:" + assert_header_value_includes middleware, "blob:" + assert_header_value_includes middleware, "ws:" + assert_header_value_includes middleware, "wss:" + assert_header_value_includes middleware, "'unsafe-eval'" + assert_header_value_includes middleware, "'unsafe-inline'" + end + + private + + def stub_csp_config(enabled:, report_only:, policy:) + ThreeScale::ContentSecurityPolicy::DeveloperPortal.stubs(:enabled?).returns(enabled) + ThreeScale::ContentSecurityPolicy::DeveloperPortal.stubs(:report_only?).returns(report_only) + ThreeScale::ContentSecurityPolicy::DeveloperPortal.stubs(:policy_config).returns(policy) + end + + def default_policy + { + default_src: ["*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'"] + } + end + + def mock_app + ->(env) { [200, {}, ['OK']] } + end + + def assert_header_name(middleware, expected_name) + actual_name = middleware.instance_variable_get(:@csp_header_name) + if expected_name.nil? + assert_nil actual_name, "Expected header name to be nil, but got #{actual_name.inspect}" + else + assert_equal expected_name, actual_name, "Expected header name to be #{expected_name.inspect}, but got #{actual_name.inspect}" + end + end + + def assert_header_value(middleware, expected_value) + actual_value = middleware.instance_variable_get(:@csp_header_value) + if expected_value.nil? + assert_nil actual_value, "Expected header value to be nil, but got #{actual_value.inspect}" + else + assert_equal expected_value, actual_value, "Expected header value to be #{expected_value.inspect}, but got #{actual_value.inspect}" + end + end + + def assert_header_value_present(middleware) + actual_value = middleware.instance_variable_get(:@csp_header_value) + assert_not_nil actual_value, "Expected header value to be present, but it was nil" + assert actual_value.is_a?(String), "Expected header value to be a String, but got #{actual_value.class}" + assert_not actual_value.empty?, "Expected header value to be non-empty" + end + + def assert_header_value_includes(middleware, substring) + actual_value = middleware.instance_variable_get(:@csp_header_value) + assert_includes actual_value, substring, "Expected header value to include #{substring.inspect}" + end +end From 07418e814a38cf8973b9f0ee71c1cceb3b326205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 12 Dec 2025 13:25:20 +0100 Subject: [PATCH 07/15] Change yaml file structure --- config/application.rb | 8 ++++- config/examples/content_security_policy.yml | 36 +++++++++++---------- lib/three_scale/content_security_policy.rb | 18 ++++++++--- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/config/application.rb b/config/application.rb index 5a01ab8570..82c03af519 100644 --- a/config/application.rb +++ b/config/application.rb @@ -257,9 +257,15 @@ def cache_store_config config.three_scale.cors.merge!(try_config_for(:cors) || {}) config.three_scale.content_security_policy = ActiveSupport::OrderedOptions.new - config.three_scale.content_security_policy.enabled = false config.three_scale.content_security_policy.merge!(try_config_for(:content_security_policy) || {}) + # Convert nested portal configurations to OrderedOptions + [:admin_portal, :developer_portal].each do |portal| + portal_config = ActiveSupport::OrderedOptions.new + portal_config.merge!(config.three_scale.content_security_policy.send(portal)) + config.three_scale.content_security_policy.send("#{portal}=", portal_config) + end + three_scale = config_for(:settings) three_scale[:error_reporting_stages] = three_scale[:error_reporting_stages].to_s.split(/\W+/) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml index c98174d5f4..42c63fdce7 100644 --- a/config/examples/content_security_policy.yml +++ b/config/examples/content_security_policy.yml @@ -1,25 +1,27 @@ base: &default - enabled: true - # Admin portal policy - restrictive but allows unsafe-inline/eval for existing code # Dynamically includes RAILS_ASSET_HOST for CDN assets if configured - admin_portal_policy: - default_src: ["'self'"] - script_src: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>"] - style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>"] - font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>"] - img_src: ["'self'", "data:", "blob:", "https:"] - connect_src: ["'self'"] - frame_src: ["'self'"] - frame_ancestors: ["'none'"] - object_src: ["'none'"] - base_uri: ["'self'"] - admin_portal_report_only: false + admin_portal: + enabled: true + report_only: false + policy: + default_src: ["'self'"] + script_src: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>"] + style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>"] + font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>"] + img_src: ["'self'", "data:", "blob:", "https:"] + connect_src: ["'self'"] + frame_src: ["'self'"] + frame_ancestors: ["'none'"] + object_src: ["'none'"] + base_uri: ["'self'"] # Developer portal policy - permissive defaults for customization - developer_portal_policy: - default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'" ] - developer_portal_report_only: false + developer_portal: + enabled: true + report_only: false + policy: + default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'" ] development: <<: *default diff --git a/lib/three_scale/content_security_policy.rb b/lib/three_scale/content_security_policy.rb index a19351a26e..57cc5a4ff2 100644 --- a/lib/three_scale/content_security_policy.rb +++ b/lib/three_scale/content_security_policy.rb @@ -9,7 +9,7 @@ def config end def enabled? - config&.enabled == true + raise NoMethodError, "#{__method__} not implemented in #{self.class}" end def policy_config @@ -46,24 +46,32 @@ def add_policy_config(policy, policy_config) class AdminPortal < Base class << self + def enabled? + config&.admin_portal&.enabled == true + end + def policy_config - config&.admin_portal_policy&.to_h || {} + config&.admin_portal&.policy || {} end def report_only? - config&.admin_portal_report_only == true + config&.admin_portal&.report_only == true end end end class DeveloperPortal < Base class << self + def enabled? + config&.developer_portal&.enabled == true + end + def policy_config - config&.developer_portal_policy&.to_h || {} + config&.developer_portal&.policy || {} end def report_only? - config&.developer_portal_report_only == true + config&.developer_portal&.report_only == true end end end From e925683cff8a186c9570c87356e53075d7f28a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Fri, 12 Dec 2025 13:25:27 +0100 Subject: [PATCH 08/15] Fix tests --- .../content_security_policy_test.rb | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/unit/three_scale/content_security_policy_test.rb b/test/unit/three_scale/content_security_policy_test.rb index aab5fc2677..4f109cc2f1 100644 --- a/test/unit/three_scale/content_security_policy_test.rb +++ b/test/unit/three_scale/content_security_policy_test.rb @@ -29,8 +29,10 @@ class ThreeScale::ContentSecurityPolicy::AdminPortalTest < ActiveSupport::TestCa assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? end - test 'policy_config returns empty hash when config.admin_portal_policy is nil' do - ThreeScale::ContentSecurityPolicy::AdminPortal.config.stubs(:admin_portal_policy).returns(nil) + test 'policy_config returns empty hash when config.admin_portal.policy is nil' do + admin_portal_config = ActiveSupport::OrderedOptions.new + admin_portal_config.policy = nil + ThreeScale::ContentSecurityPolicy::AdminPortal.config.stubs(:admin_portal).returns(admin_portal_config) policy_hash = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config assert_equal({}, policy_hash) @@ -66,8 +68,10 @@ class ThreeScale::ContentSecurityPolicy::DeveloperPortalTest < ActiveSupport::Te assert_equal false, ThreeScale::ContentSecurityPolicy::DeveloperPortal.report_only? end - test 'policy_config returns empty hash when config.developer_portal_policy is nil' do - ThreeScale::ContentSecurityPolicy::DeveloperPortal.config.stubs(:developer_portal_policy).returns(nil) + test 'policy_config returns empty hash when config.developer_portal.policy is nil' do + developer_portal_config = ActiveSupport::OrderedOptions.new + developer_portal_config.policy = nil + ThreeScale::ContentSecurityPolicy::DeveloperPortal.config.stubs(:developer_portal).returns(developer_portal_config) policy_hash = ThreeScale::ContentSecurityPolicy::DeveloperPortal.policy_config assert_equal({}, policy_hash) @@ -100,7 +104,9 @@ class ThreeScale::ContentSecurityPolicyWithoutYAMLTest < ActiveSupport::TestCase test 'AdminPortal enabled? handles nil config values gracefully' do config = ActiveSupport::OrderedOptions.new - config.enabled = nil + admin_portal_config = ActiveSupport::OrderedOptions.new + admin_portal_config.enabled = nil + config.admin_portal = admin_portal_config ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(config) assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? @@ -108,7 +114,9 @@ class ThreeScale::ContentSecurityPolicyWithoutYAMLTest < ActiveSupport::TestCase test 'AdminPortal report_only? handles nil config values gracefully' do config = ActiveSupport::OrderedOptions.new - config.admin_portal_report_only = nil + admin_portal_config = ActiveSupport::OrderedOptions.new + admin_portal_config.report_only = nil + config.admin_portal = admin_portal_config ThreeScale::ContentSecurityPolicy::AdminPortal.stubs(:config).returns(config) assert_equal false, ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? From 537d02853b1c890416dcf6e7bbd7c93f53f6816b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Wed, 7 Jan 2026 10:59:06 +0100 Subject: [PATCH 09/15] CSP config load: add safety mechanism So that the app doesn't crash if the CSP yaml is not there. Co-authored-by: Daria Mayorova --- config/application.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/application.rb b/config/application.rb index 82c03af519..8d61d3e75f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -262,7 +262,7 @@ def cache_store_config # Convert nested portal configurations to OrderedOptions [:admin_portal, :developer_portal].each do |portal| portal_config = ActiveSupport::OrderedOptions.new - portal_config.merge!(config.three_scale.content_security_policy.send(portal)) + portal_config.merge!(config.three_scale.content_security_policy.send(portal) || {}) config.three_scale.content_security_policy.send("#{portal}=", portal_config) end From 7c69d357a70d764953f04430d09fad529bfcb986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Wed, 7 Jan 2026 13:06:55 +0100 Subject: [PATCH 10/15] Don't overwrite dev portal default policy When dev portal policy if disabled, ensure the default is used instead of taking the one from the admin portal --- config/initializers/content_security_policy.rb | 5 ++++- .../three_scale/middleware/developer_portal_csp.rb | 12 +++++++++++- lib/three_scale/content_security_policy.rb | 5 +++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 9c1e630948..4f4afb688c 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -19,7 +19,10 @@ config.content_security_policy_report_only = true if ThreeScale::ContentSecurityPolicy::AdminPortal.report_only? else config.content_security_policy do |policy| - policy.default_src '*', :data, :mediastream, :blob, :filesystem, :ws, :wss, :unsafe_eval, :unsafe_inline + ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config( + policy, + ThreeScale::ContentSecurityPolicy::AdminPortal::DEFAULT_POLICY + ) end end end diff --git a/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb b/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb index c5e107d9db..6bcc7330e2 100644 --- a/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb +++ b/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb @@ -38,7 +38,17 @@ def call(env) def compute_csp_header # Only compute if enabled and there's a policy configured policy_config = ThreeScale::ContentSecurityPolicy::DeveloperPortal.policy_config - return [nil, nil] unless ThreeScale::ContentSecurityPolicy::DeveloperPortal.enabled? && policy_config.present? + + unless ThreeScale::ContentSecurityPolicy::DeveloperPortal.enabled? && policy_config.present? + policy = ThreeScale::ContentSecurityPolicy::DeveloperPortal.build_policy( + ThreeScale::ContentSecurityPolicy::DeveloperPortal::DEFAULT_POLICY + ) + + return [ + ActionDispatch::Constants::CONTENT_SECURITY_POLICY, + policy.build + ] + end # Build the policy once at initialization policy = ThreeScale::ContentSecurityPolicy::DeveloperPortal.build_policy(policy_config) diff --git a/lib/three_scale/content_security_policy.rb b/lib/three_scale/content_security_policy.rb index 57cc5a4ff2..1e9422493a 100644 --- a/lib/three_scale/content_security_policy.rb +++ b/lib/three_scale/content_security_policy.rb @@ -3,6 +3,11 @@ module ThreeScale module ContentSecurityPolicy class Base + + DEFAULT_POLICY = { + default_src: ['*', :data, :mediastream, :blob, :filesystem, :ws, :wss, :unsafe_eval, :unsafe_inline] + }.freeze + class << self def config @config ||= Rails.configuration.three_scale.content_security_policy From 95d16ba5d8e4945bf5d2c3017f743b096ae14a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Wed, 7 Jan 2026 14:17:04 +0100 Subject: [PATCH 11/15] Fix tests --- .../middleware/developer_portal_csp_test.rb | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/unit/three_scale/middleware/developer_portal_csp_test.rb b/test/unit/three_scale/middleware/developer_portal_csp_test.rb index 6cce898765..ae471391a4 100644 --- a/test/unit/three_scale/middleware/developer_portal_csp_test.rb +++ b/test/unit/three_scale/middleware/developer_portal_csp_test.rb @@ -24,31 +24,34 @@ class ThreeScale::Middleware::DeveloperPortalCSPTest < ActiveSupport::TestCase assert_header_value_includes middleware, "default-src *" end - test 'initializes with no header when CSP is disabled' do + test 'initializes with permissive default policy when CSP is disabled' do stub_csp_config(enabled: false, report_only: false, policy: default_policy) middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) - assert_header_name middleware, nil - assert_header_value middleware, nil + assert_header_name middleware, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" end - test 'initializes with no header when policy_config is nil' do + test 'initializes with permissive default policy when policy_config is nil' do stub_csp_config(enabled: true, report_only: false, policy: nil) middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) - assert_header_name middleware, nil - assert_header_value middleware, nil + assert_header_name middleware, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" end - test 'initializes with no header when policy_config is empty' do + test 'initializes with permissive default policy when policy_config is empty' do stub_csp_config(enabled: true, report_only: false, policy: {}) middleware = ThreeScale::Middleware::DeveloperPortalCSP.new(mock_app) - assert_header_name middleware, nil - assert_header_value middleware, nil + assert_header_name middleware, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" end test 'initializes with custom policy directives' do From ea2d6d579b05902bf37b15764ce66900bad0e351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Wed, 14 Jan 2026 14:23:05 +0100 Subject: [PATCH 12/15] Allow webpack as source in development --- config/examples/content_security_policy.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml index 42c63fdce7..bf052acc5e 100644 --- a/config/examples/content_security_policy.yml +++ b/config/examples/content_security_policy.yml @@ -4,7 +4,7 @@ base: &default admin_portal: enabled: true report_only: false - policy: + policy: &default_admin_policy default_src: ["'self'"] script_src: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>"] style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>"] @@ -25,6 +25,15 @@ base: &default development: <<: *default + admin_portal: + enabled: true + report_only: false + policy: + <<: *default_admin_policy # Overwrite default policy in development to add webpack as source + script_src: [ "'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035" ] + style_src: [ "'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035" ] + font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035" ] + connect_src: ["localhost:3035", "ws://localhost:3035" ] test: <<: *default From 6e89bd7a3fbfaeb549a5d603918c070cde7fb4d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Thu, 15 Jan 2026 11:05:09 +0100 Subject: [PATCH 13/15] Fix webpack workaround --- config/examples/content_security_policy.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml index bf052acc5e..8f2b6b4dc8 100644 --- a/config/examples/content_security_policy.yml +++ b/config/examples/content_security_policy.yml @@ -9,7 +9,7 @@ base: &default script_src: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>"] style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>"] font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>"] - img_src: ["'self'", "data:", "blob:", "https:"] + img_src: ["'self'", "data:", "blob:", "https:", "<%= ENV['RAILS_ASSET_HOST'] %>"] connect_src: ["'self'"] frame_src: ["'self'"] frame_ancestors: ["'none'"] @@ -21,7 +21,7 @@ base: &default enabled: true report_only: false policy: - default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'" ] + default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'"] development: <<: *default @@ -30,10 +30,11 @@ development: report_only: false policy: <<: *default_admin_policy # Overwrite default policy in development to add webpack as source - script_src: [ "'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035" ] - style_src: [ "'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035" ] - font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035" ] - connect_src: ["localhost:3035", "ws://localhost:3035" ] + script_src: [ "'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + style_src: [ "'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + img_src: ["'self'", "data:", "blob:", "https:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + connect_src: ["'self'", "localhost:3035", "ws://localhost:3035"] test: <<: *default From d355854cdd3bc68b5b69d26e4a2097bd80c7856d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Thu, 15 Jan 2026 13:14:42 +0100 Subject: [PATCH 14/15] Allow api docs requests --- config/examples/content_security_policy.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml index 8f2b6b4dc8..5fb11a8fdf 100644 --- a/config/examples/content_security_policy.yml +++ b/config/examples/content_security_policy.yml @@ -10,7 +10,7 @@ base: &default style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>"] font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>"] img_src: ["'self'", "data:", "blob:", "https:", "<%= ENV['RAILS_ASSET_HOST'] %>"] - connect_src: ["'self'"] + connect_src: ["*"] frame_src: ["'self'"] frame_ancestors: ["'none'"] object_src: ["'none'"] @@ -21,7 +21,7 @@ base: &default enabled: true report_only: false policy: - default_src: [ "*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'"] + default_src: ["*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'"] development: <<: *default @@ -30,11 +30,11 @@ development: report_only: false policy: <<: *default_admin_policy # Overwrite default policy in development to add webpack as source - script_src: [ "'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] - style_src: [ "'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + script_src: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + style_src: ["'self'", "'unsafe-inline'", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] img_src: ["'self'", "data:", "blob:", "https:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] - connect_src: ["'self'", "localhost:3035", "ws://localhost:3035"] + connect_src: ["*", "localhost:3035", "ws://localhost:3035"] test: <<: *default From 036fa9f05b396fd182f2eae2b6d6a19e30e51969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joan=20Lled=C3=B3?= Date: Tue, 20 Jan 2026 13:13:11 +0100 Subject: [PATCH 15/15] Disable CSP by default --- config/examples/content_security_policy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/examples/content_security_policy.yml b/config/examples/content_security_policy.yml index 5fb11a8fdf..9b1a9f8610 100644 --- a/config/examples/content_security_policy.yml +++ b/config/examples/content_security_policy.yml @@ -41,3 +41,7 @@ test: production: <<: *default + admin_portal: + enabled: false + developer_portal: + enabled: false