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..8d61d3e75f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -256,6 +256,16 @@ 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.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 new file mode 100644 index 0000000000..9b1a9f8610 --- /dev/null +++ b/config/examples/content_security_policy.yml @@ -0,0 +1,47 @@ +base: &default + # Admin portal policy - restrictive but allows unsafe-inline/eval for existing code + # Dynamically includes RAILS_ASSET_HOST for CDN assets if configured + admin_portal: + enabled: true + report_only: false + 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'] %>"] + font_src: ["'self'", "data:", "<%= ENV['RAILS_ASSET_HOST'] %>"] + img_src: ["'self'", "data:", "blob:", "https:", "<%= ENV['RAILS_ASSET_HOST'] %>"] + connect_src: ["*"] + frame_src: ["'self'"] + frame_ancestors: ["'none'"] + object_src: ["'none'"] + base_uri: ["'self'"] + + # Developer portal policy - permissive defaults for customization + developer_portal: + enabled: true + report_only: false + policy: + default_src: ["*", "data:", "mediastream:", "blob:", "filesystem:", "ws:", "wss:", "'unsafe-eval'", "'unsafe-inline'"] + +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"] + img_src: ["'self'", "data:", "blob:", "https:", "<%= ENV['RAILS_ASSET_HOST'] %>", "localhost:3035", "ws://localhost:3035"] + connect_src: ["*", "localhost:3035", "ws://localhost:3035"] + +test: + <<: *default + +production: + <<: *default + admin_portal: + enabled: false + developer_portal: + enabled: false diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 601f89e6d4..4f4afb688c 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,31 +1,28 @@ -# 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 'three_scale/content_security_policy' + +Rails.application.configure do + if ThreeScale::ContentSecurityPolicy::AdminPortal.enabled? + policy_config = ThreeScale::ContentSecurityPolicy::AdminPortal.policy_config + + if policy_config.present? + config.content_security_policy do |policy| + ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config(policy, policy_config) + end + end + + # 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| + ThreeScale::ContentSecurityPolicy::AdminPortal.add_policy_config( + policy, + ThreeScale::ContentSecurityPolicy::AdminPortal::DEFAULT_POLICY + ) + end 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 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/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/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..6bcc7330e2 --- /dev/null +++ b/lib/developer_portal/lib/three_scale/middleware/developer_portal_csp.rb @@ -0,0 +1,66 @@ +# 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 + + 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) + 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..1e9422493a --- /dev/null +++ b/lib/three_scale/content_security_policy.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +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 + end + + def enabled? + raise NoMethodError, "#{__method__} not implemented in #{self.class}" + end + + def policy_config + raise NoMethodError, "#{__method__} not implemented in #{self.class}" + end + + def report_only? + raise NoMethodError, "#{__method__} not implemented in #{self.class}" + 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 enabled? + config&.admin_portal&.enabled == true + end + + def policy_config + config&.admin_portal&.policy || {} + end + + def report_only? + 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 || {} + end + + def report_only? + config&.developer_portal&.report_only == true + end + end + end + end +end 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..4f109cc2f1 --- /dev/null +++ b/test/unit/three_scale/content_security_policy_test.rb @@ -0,0 +1,124 @@ +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 + 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) + 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 + 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) + 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 + 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? + end + + test 'AdminPortal report_only? handles nil config values gracefully' do + config = ActiveSupport::OrderedOptions.new + 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? + end +end 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..ae471391a4 --- /dev/null +++ b/test/unit/three_scale/middleware/developer_portal_csp_test.rb @@ -0,0 +1,137 @@ +# 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 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, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" + end + + 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, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" + end + + 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, 'Content-Security-Policy' + assert_header_value_present middleware + assert_header_value_includes middleware, "default-src *" + 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