CVE-2023-37467 (Ruby, published 2023-07-28T15:15Z)
Description: value: Discourse is an open source discussion platform. Prior to version 3.1.0.beta7 of the `beta` and `tests-passed` branches, a CSP (Content Security Policy) nonce reuse vulnerability was discovered could allow cross-site scripting (XSS) attacks to bypass CSP protection for anonymous (i.e. unauthenticated) users. There are no known XSS vectors at the moment, but should one be discovered, this vulnerability would allow the XSS attack to bypass CSP and execute successfully. This vulnerability isn't applicable to logged-in users. Version 3.1.0.beta7 contains a patch. The stable branch doesn't have this vulnerability. A workaround to prevent the vulnerability is to disable Google Tag Manager, i.e., unset the `gtm container id` setting.
Severity: nan (CVSS v3 base score: 5.4)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
Repository: https://github.com/discourse/discourse
Files changed:
app/helpers/application_helper.rb
app/views/common/_google_tag_manager_head.html.erb
config/application.rb
config/initializers/099-anon-cache.rb
lib/content_security_policy.rb
lib/content_security_policy/builder.rb
lib/content_security_policy/default.rb
lib/content_security_policy/middleware.rb
lib/middleware/anonymous_cache.rb
lib/middleware/gtm_script_nonce_injector.rb
spec/lib/content_security_policy_spec.rb
spec/lib/middleware/anonymous_cache_spec.rb
spec/lib/middleware/request_tracker_spec.rb
spec/rails_helper.rb
spec/requests/application_controller_spec.rb
Commit message: SECURITY: Don't reuse CSP nonce between anonymous requests


Vulnerable code:
  def self.google_tag_manager_nonce(env)
    env[:discourse_content_security_policy_nonce] ||= SecureRandom.hex
  data-nonce="<%= ApplicationHelper.google_tag_manager_nonce(request.env) %>"
    Rails.env.production?
  Rails.configuration.middleware.insert_after ActionDispatch::Flash, Middleware::AnonymousCache
    def policy(theme_id = nil, env: {}, base_url: Discourse.base_url, path_info: "/")
      new.build(theme_id, env: env, base_url: base_url, path_info: path_info)
  def build(theme_id, env: {}, base_url:, path_info: "/")
    builder = Builder.new(base_url: base_url, env: env)
    def initialize(base_url:, env: {})
      @directives = Default.new(base_url: base_url, env: env).directives
    def initialize(base_url:, env: {})
      @env = env
          sources << "'nonce-#{ApplicationHelper.google_tag_manager_nonce(@env)}'"
        env: env,
        env: env,
        !!(!has_auth_cookie? && get? && no_cache_bypass)
      expect(script_srcs.to_s).to include("nonce-")
      expect(response.headers).to include("Content-Security-Policy")
    it "doesn't reuse CSP nonces between requests" do
      expect(response.headers).to include("Content-Security-Policy")
      expect(response.headers).to include("Content-Security-Policy")

Fixed code:
  def google_tag_manager_nonce_placeholder
    placeholder = "[[csp_nonce_placeholder_#{SecureRandom.hex}]]"
    response.headers["Discourse-GTM-Nonce-Placeholder"] = placeholder
    placeholder
  data-nonce="<%= google_tag_manager_nonce_placeholder %>"
    require "middleware/gtm_script_nonce_injector"
    config.middleware.insert_after(ActionDispatch::Flash, Middleware::GtmScriptNonceInjector)

    Rails.env.production? || Rails.env.test?
  Rails.configuration.middleware.insert_after Middleware::GtmScriptNonceInjector,
                                              Middleware::AnonymousCache
    def policy(theme_id = nil, base_url: Discourse.base_url, path_info: "/")
      new.build(theme_id, base_url: base_url, path_info: path_info)
  def build(theme_id, base_url:, path_info: "/")
    builder = Builder.new(base_url: base_url)
    def initialize(base_url:)
      @directives = Default.new(base_url: base_url).directives
    def initialize(base_url:)
    def self.clear_all_cache!
      if Rails.env.production?
        raise "for perf reasons, clear_all_cache! cannot be used in production."
      end
      Discourse.redis.keys("ANON_CACHE_*").each { |k| Discourse.redis.del(k) }
    end

    def self.disable_anon_cache
      @@disabled = true
    end

    def self.enable_anon_cache
      @@disabled = false
    end

        !!(
          GlobalSetting.anon_cache_store_threshold > 0 && !has_auth_cookie? && get? &&
            no_cache_bypass
        )
      return @app.call(env) if defined?(@@disabled) && @@disabled

# frozen_string_literal: true

module Middleware
  class GtmScriptNonceInjector
    def initialize(app, settings = {})
      @app = app
    end

    def call(env)
      status, headers, response = @app.call(env)

      if nonce_placeholder = headers.delete("Discourse-GTM-Nonce-Placeholder")
        nonce = SecureRandom.hex
        parts = []
        response.each { |part| parts << part.to_s.sub(nonce_placeholder, nonce) }
        %w[Content-Security-Policy Content-Security-Policy-Report-Only].each do |name|
          next if headers[name].blank?
          headers[name] = headers[name].sub("script-src ", "script-src 'nonce-#{nonce}' ")
        end
        [status, headers, parts]
      else
        [status, headers, response]
      end
    end
  end
end
      # nonce is added by the GtmScriptNonceInjector middleware to prevent the
      # nonce from getting cached by AnonymousCache
      expect(script_srcs.to_s).not_to include("nonce-")
  before { Middleware::AnonymousCache.enable_anon_cache }

      Middleware::AnonymousCache.enable_anon_cache


    Middleware::AnonymousCache.disable_anon_cache
      SiteSetting.content_security_policy_report_only = true
      report_only_script_src =
        parse(response.headers["Content-Security-Policy-Report-Only"])["script-src"]

      report_only_nonce = extract_nonce_from_script_src(report_only_script_src)

      expect(nonce).to eq(report_only_nonce)
    it "doesn't reuse nonces between requests" do
      global_setting :anon_cache_store_threshold, 1
      Middleware::AnonymousCache.enable_anon_cache
      Middleware::AnonymousCache.clear_all_cache!

      SiteSetting.content_security_policy_report_only = true
      expect(response.headers["X-Discourse-Cached"]).to eq("store")
      expect(response.headers).not_to include("Discourse-GTM-Nonce-Placeholder")

      report_only_script_src =
        parse(response.headers["Content-Security-Policy-Report-Only"])["script-src"]

      first_report_only_nonce = extract_nonce_from_script_src(report_only_script_src)

      expect(first_nonce).to eq(first_report_only_nonce)
      expect(response.headers["X-Discourse-Cached"]).to eq("true")
      expect(response.headers).not_to include("Discourse-GTM-Nonce-Placeholder")

      report_only_script_src =
        parse(response.headers["Content-Security-Policy-Report-Only"])["script-src"]

      second_report_only_nonce = extract_nonce_from_script_src(report_only_script_src)

      expect(second_nonce).to eq(second_report_only_nonce)