diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index a4fdd36b047..6b65694e6b9 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -957,6 +957,13 @@ module Settings writable: false, description: "Host the frontend uses to download files, which has to be added to the CSP." }, + # Content Security Policy + csp_img_src: { + format: :array, + default: %w(* data: blob:), + writable: false, + description: "Allowed sources for the CSP img-src directive." + }, report_incoming_email_errors: { description: "Respond to incoming mails with error details", default: true diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index f9c1afa3f2e..8d40c9155dc 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -34,6 +34,7 @@ # See the Securing Rails Applications Guide for more information: # https://guides.rubyonrails.org/security.html#content-security-policy-header +# rubocop:disable Lint/PercentStringArray Rails.application.config.after_initialize do Rails.application.configure do config.content_security_policy do |policy| @@ -125,7 +126,9 @@ Rails.application.config.after_initialize do policy.form_action(*form_action) policy.frame_src(*frame_src, "'self'") policy.frame_ancestors("'self'") - policy.img_src("*", "data:", "blob:") + img_src = %w('self') + Array(OpenProject::Configuration.csp_img_src) + img_src << asset_host if asset_host.present? + policy.img_src(*img_src.compact.uniq) policy.script_src(*script_src) policy.script_src_attr("'none'") policy.style_src(*assets_src, "'unsafe-inline'") @@ -149,3 +152,4 @@ Rails.application.config.after_initialize do config.content_security_policy_nonce_directives = %w(script-src) end end +# rubocop:enable Lint/PercentStringArray diff --git a/docs/installation-and-operations/configuration/README.md b/docs/installation-and-operations/configuration/README.md index fc6ac60120c..3b2121878a9 100644 --- a/docs/installation-and-operations/configuration/README.md +++ b/docs/installation-and-operations/configuration/README.md @@ -715,6 +715,26 @@ To disable rendering the badge, uncheck the setting at Administration > Syste OPENPROJECT_SECURITY__BADGE__DISPLAYED="false" ``` +### Content Security Policy image sources + +Configure the allowed sources for the `img-src` CSP directive. + +*default: `["*", "data:", "blob:"]`* + +OpenProject always adds `'self'` and `rails_asset_host` (if configured) to `img-src` automatically, so same-origin and asset-hosted images remain allowed even if not listed in this setting. + +Example to only allow secure remote images (plus data/blob): + +```yaml +OPENPROJECT_CSP__IMG__SRC="https: data: blob:" +``` + +Example to restrict to specific hosts: + +```yaml +OPENPROJECT_CSP__IMG__SRC="https://cdn.example.com https://images.example.com data: blob:" +``` + ### Cache configuration options diff --git a/docs/installation-and-operations/configuration/environment/README.md b/docs/installation-and-operations/configuration/environment/README.md index 1f6ab3c5327..f4772257e3b 100644 --- a/docs/installation-and-operations/configuration/environment/README.md +++ b/docs/installation-and-operations/configuration/environment/README.md @@ -180,6 +180,7 @@ OPENPROJECT_COST__REPORTING__CACHE__FILTER__CLASSES (default=true) OPENPROJECT_COSTS__CURRENCY (default="EUR") Currency OPENPROJECT_COSTS__CURRENCY__FORMAT (default="%n %u") Format of currency OPENPROJECT_CROSS__PROJECT__WORK__PACKAGE__RELATIONS (default=true) Allow cross-project work package relations +OPENPROJECT_CSP__IMG__SRC (default=["*", "data:", "blob:"]) Allowed sources for the CSP img-src directive. OPENPROJECT_DATABASE__CIPHER__KEY (default=nil) Encryption key for repository credentials OPENPROJECT_DATE__FORMAT (default=nil) Date OPENPROJECT_DAYS__PER__MONTH (default=20) This will define what is considered a “month” when displaying duration in a more natural way (for example, if a month is 20 days, 60 days would be 3 months. diff --git a/spec/requests/dynamic_content_security_policy_spec.rb b/spec/requests/dynamic_content_security_policy_spec.rb index b467e298365..f29c75b7f15 100644 --- a/spec/requests/dynamic_content_security_policy_spec.rb +++ b/spec/requests/dynamic_content_security_policy_spec.rb @@ -74,5 +74,19 @@ RSpec.describe "" do csp = parse_csp(last_response.headers["Content-Security-Policy"]) expect(csp["font-src"].count("'self'")).to eq(1) end + + it "includes 'self' in img-src CSP directive" do + get "/" + + csp = parse_csp(last_response.headers["Content-Security-Policy"]) + expect(csp["img-src"]).to include("'self'") + end + + it "does not duplicate 'self' in img-src CSP directive" do + get "/" + + csp = parse_csp(last_response.headers["Content-Security-Policy"]) + expect(csp["img-src"].count("'self'")).to eq(1) + end end end