Skip to content
Open
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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+/)
Expand Down
47 changes: 47 additions & 0 deletions config/examples/content_security_policy.yml
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern is that these security headers, including the Browser Permissions header, may require multiple files modification and corresponding Operator support. So I wonder if it makes sense to put this config in the database (e.g. the settings table) or maybe at least in a single config file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit reluctant to create migrations if possible. Would this approach imply so much trouble for the operator? I think the CSP feature can be completely disabled by default, and whoever wants to enable it, would just need to add a new entry to one configmap. However, in our side we would need to add the migration and then the UI or API to CRUD the data, tests, etc. Which would imply probably rejecting this PR and start a new one. Is it worth it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't go the database/migration way.

We probably just need to either double-check what's the easiest way to customize the config with existing methods, or open an issue for operator to make it easier for customers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is all secrets with the files inside like other config files. The issue with that is that as soon as we change something on our side to require a config change, customers will very likely miss to update the config on their end. For example if they want to enable a future captcha provider.

On the other hand, having it in a DB just makes it easier to fix that. But will also allow flexibility of a per-portal configuration 🤔

I would go with a separate account settings table, not like the current one, because current one if rigid and hard to extend. Something like:

class CreateSettings < ActiveRecord::Migration[7.1]
  def change
    create_table :settings do |t|
      t.integer :account_id, null: false
      t.string :name, null: false
      t.text :value

      t.timestamps
    end

    add_index :settings, [:account_id, :name], unique: true
  end
end

I think proper extendable settings will make a lot of sense for 2.17 and avoid this issue to introduce new configuration files for every new feature we want to implement. With such a settings table we can dynamically add and remove settings options.

Copy link
Contributor Author

@jlledom jlledom Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @akostadinov. I appreciate your efforts to annoy me and I have to admit you're becoming a true professional on this art.

How strong is your opinion on this? If it's really strong I'll close this PR and start working on your approach. But first please consider:

  1. I already spent 44 hours and 10 minutes on this PR (I have a counter), that could be wasted if I close the PR.
  2. It would hurt me in the bottom of my heart, in the exact spot where feelings are born, to close this after I spent so much effort on it.
  3. Man, the PR is open for more than a month now, you could have stopped me before.

Are you pushing the red button?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't have a concept before. I think you did an amazing job with this. And 44hours is not lost. Just I don't find this maintenance friendly and not user friendly. That's why. I wouldn't hard stop you but to me it makes no sense to dig ourselves more into the same problem. So you decide.

Original file line number Diff line number Diff line change
@@ -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'] %>"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we have some internal way to access this, instead of env variable btw?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the top of my head: most probably yes. But then we'll have to worry about config load order to ensure the relevant rails config is already set when this yaml is parsed. I think taking the env variable is simpler here.

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
51 changes: 24 additions & 27 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be a bit confusing...

So, if admin_portal.enabled: true, and developer_portal.enabled: false, the admin portal's custom policy from YAML is applied for the developer portal too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I fixed it: 7c69d35

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
1 change: 1 addition & 0 deletions config/initializers/inflections.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions features/step_definitions/asset_host_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/developer_portal/lib/developer_portal/engine.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
# frozen_string_literal: true

require 'three_scale/middleware/developer_portal_csp'

module DeveloperPortal
class Engine < ::Rails::Engine
isolate_namespace DeveloperPortal

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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit complicated for me... because I have no idea how this is supposed to work. But it seems that most of the logic is similar to what https://github.com/rails/rails/blob/v7.1.5.2/actionpack/lib/action_dispatch/http/content_security_policy.rb#L35 does.

I guess it's fine... It's unfortunate though that we (apparently) cannot reuse the existing logic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, basically, I created this class and added it to the developer portal middleware stack.

  • On startup, a new instance will be initialized.
  • On every request, the call method will be called.

Our call method is called by the previous middleware in the stack. By calling @app.call(env) we yield control to the next middleware in the stack, and eventually to the controller.

This class basically generates the headers once on startup and then adds them to each request. We can't reuse the existing middleware because that one takes the CSP policy from the Rails global CSP configuration, but we need to take it from our yaml file.

However, the Rails CSP middleware is in fact installed also in the stack, so we are calling it anyway, that's why we have this snippet:

unless request.format.html?
  request.content_security_policy = false
  return @app.call(env)
end

When the request is HTML, we handle it; when it's not, we don't, and ensure the rails middlware doesn't handle it neither.

Copy link
Contributor Author

@jlledom jlledom Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Admin portal: Yaml -> Rails Global CSP config -> Rails CSP middleware
  • Dev portal: Yaml -> Our dev portal CSP middleware


# 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
84 changes: 84 additions & 0 deletions lib/three_scale/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -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
Loading