diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 5b094278b24..1c33988283c 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1844,7 +1844,7 @@ en:
content_security_policy_report_only: "Enable Content-Security-Policy-Report-Only (CSP)"
content_security_policy_collect_reports: "Enable CSP violation report collection at /csp_reports"
content_security_policy_frame_ancestors: "Restrict who can embed this site in iframes via CSP. Control allowed hosts on Embedding"
- content_security_policy_script_src: "Additional allowlisted script sources. The current host and CDN are included by default. See Mitigate XSS Attacks with Content Security Policy. (CSP). Host sources will be ignored when content_security_policy_strict_dynamic is enabled."
+ content_security_policy_script_src: "Additional allowlisted script sources. The current host and CDN are included by default. See Mitigate XSS Attacks with Content Security Policy. (CSP). Other host sources are ignored as strict-dynamic is enabled."
invalidate_inactive_admin_email_after_days: "Admin accounts that have not visited the site in this number of days will need to re-validate their email address before logging in. Set to 0 to disable."
include_secure_categories_in_tag_counts: "When enabled, count of topics for a tag will include topics that are in read restricted categories for all users. When disabled, normal users are only shown a count of topics for a tag where all the topics are in public categories."
display_personal_messages_tag_counts: "When enabled, count of personal messages tagged with a given tag will be displayed."
@@ -2689,6 +2689,7 @@ en:
invalid_reply_by_email_address: "Value must contain '%{reply_key}' and be different from the notification email."
invalid_alternative_reply_by_email_addresses: "All values must contain '%{reply_key}' and be different from the notification email."
invalid_domain_hostname: "Must not include * or ? characters."
+ invalid_csp_script_src: "Value must be either 'unsafe-eval' or 'wasm-unsafe-eval', or in the form '-' where supported hash algorithms are sha256, sha384 or sha512. Ensure that your input is wrapped in single quotation marks."
pop3_polling_host_is_empty: "You must set a 'pop3 polling host' before enabling POP3 polling."
pop3_polling_username_is_empty: "You must set a 'pop3 polling username' before enabling POP3 polling."
pop3_polling_password_is_empty: "You must set a 'pop3 polling password' before enabling POP3 polling."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 6766ca226e2..d2f012291c2 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2025,6 +2025,7 @@ security:
content_security_policy_script_src:
type: simple_list
default: ""
+ validator: "CspScriptSrcValidator"
invalidate_inactive_admin_email_after_days:
default: 365
min: 0
diff --git a/lib/validators/csp_script_src_validator.rb b/lib/validators/csp_script_src_validator.rb
new file mode 100644
index 00000000000..419a6cf456d
--- /dev/null
+++ b/lib/validators/csp_script_src_validator.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class CspScriptSrcValidator
+ VALID_SOURCE_REGEX =
+ /
+ (?:\A'unsafe-eval'\z)|
+ (?:\A'wasm-unsafe-eval'\z)|
+ (?:\A'sha(?:256|384|512)-[A-Za-z0-9+\/\-_]+={0,2}'\z)
+ /x
+
+ def initialize(opts = {})
+ @opts = opts
+ end
+
+ def valid_value?(values)
+ values.split("|").all? { _1.match? VALID_SOURCE_REGEX }
+ end
+
+ def error_message
+ I18n.t("site_settings.errors.invalid_csp_script_src")
+ end
+end
diff --git a/spec/lib/content_security_policy_spec.rb b/spec/lib/content_security_policy_spec.rb
index 4b7847e6776..eac5956bddb 100644
--- a/spec/lib/content_security_policy_spec.rb
+++ b/spec/lib/content_security_policy_spec.rb
@@ -129,16 +129,6 @@ RSpec.describe ContentSecurityPolicy do
expect(parse(policy)["script-src"]).to include("'unsafe-eval'")
end
- it "strips unsupported values from setting" do
- SiteSetting.content_security_policy_script_src =
- "'unsafe-eval'|blob:|https://example.com/script.js"
-
- script_src = parse(policy)["script-src"]
- expect(script_src).to include("'unsafe-eval'")
- expect(script_src).not_to include("blob:")
- expect(script_src).not_to include("https://example.com/script.js")
- end
-
def parse(csp_string)
csp_string
.split(";")
diff --git a/spec/lib/validators/csp_script_src_validator_spec.rb b/spec/lib/validators/csp_script_src_validator_spec.rb
new file mode 100644
index 00000000000..4359b5dce3b
--- /dev/null
+++ b/spec/lib/validators/csp_script_src_validator_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+RSpec.describe CspScriptSrcValidator do
+ describe "#valid_value?" do
+ context "when values are valid" do
+ context "when value is an empty string" do
+ it { is_expected.to be_a_valid_value "" }
+ end
+
+ context "when there's a single value" do
+ %w[
+ 'unsafe-eval'
+ 'wasm-unsafe-eval'
+ 'sha256-valid_h4sH'
+ 'sha384-valid-h4sH='
+ 'sha512-valid+h4sH=='
+ ].each { |valid_value| it { is_expected.to be_a_valid_value valid_value } }
+ end
+
+ context "when there are multiple valid values" do
+ let(:valid_values) do
+ %w[
+ 'unsafe-eval'
+ 'wasm-unsafe-eval'
+ 'sha384-oqVuAfXRKap7fdgcCY5-ykM6+R9GqQ8K/uxy9rx_HNQlGYl1kPzQho1wx4JwY8wC'
+ ].join("|")
+ end
+
+ it { is_expected.to be_a_valid_value valid_values }
+ end
+ end
+
+ context "when values are invalid" do
+ context "when there's a single value" do
+ %w[
+ unsafe-eval
+ 'unsafe-eval'!
+ !'unsafe-eval'
+ 'sha256-not+a+valid+base64===='
+ 'md5-not+a+supported+hash+algo'
+ 'sha224-not+a+supported+hash+algo'
+ ].each { |invalid_value| it { is_expected.not_to be_a_valid_value invalid_value } }
+ end
+
+ context "when there is at least 1 invalid value and 1 valid value" do
+ it { is_expected.not_to be_a_valid_value "'unsafe-eval'|'md5-not+a+supported+hash+algo'" }
+ end
+ end
+ end
+end