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