Move CSP to Rails

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