mirror of
https://github.com/discourse/discourse.git
synced 2025-02-28 08:37:58 +08:00
SECURITY: Don't reuse CSP nonce between requests (#22553)
Co-authored-by: OsamaSayegh <asooomaasoooma90@gmail.com>
This commit is contained in:
parent
51c53e6007
commit
06ab681498
@ -64,8 +64,10 @@ module ApplicationHelper
|
||||
google_universal_analytics_json
|
||||
end
|
||||
|
||||
def self.google_tag_manager_nonce
|
||||
@gtm_nonce ||= SecureRandom.hex
|
||||
def google_tag_manager_nonce_placeholder
|
||||
placeholder = "[[csp_nonce_placeholder_#{SecureRandom.hex}]]"
|
||||
response.headers["Discourse-GTM-Nonce-Placeholder"] = placeholder
|
||||
placeholder
|
||||
end
|
||||
|
||||
def shared_session_key
|
||||
|
@ -1,6 +1,6 @@
|
||||
<meta id="data-google-tag-manager"
|
||||
data-data-layer="<%= google_tag_manager_json %>"
|
||||
data-nonce="<%= ApplicationHelper.google_tag_manager_nonce %>"
|
||||
data-nonce="<%= google_tag_manager_nonce_placeholder %>"
|
||||
data-container-id="<%= SiteSetting.gtm_container_id %>" />
|
||||
|
||||
<%= preload_script 'google-tag-manager' %>
|
||||
|
@ -168,6 +168,9 @@ module Discourse
|
||||
config.middleware.swap ActionDispatch::ContentSecurityPolicy::Middleware,
|
||||
ContentSecurityPolicy::Middleware
|
||||
|
||||
require "middleware/gtm_script_nonce_injector"
|
||||
config.middleware.insert_after(ActionDispatch::Flash, Middleware::GtmScriptNonceInjector)
|
||||
|
||||
require "middleware/discourse_public_exceptions"
|
||||
config.exceptions_app = Middleware::DiscoursePublicExceptions.new(Rails.public_path)
|
||||
|
||||
|
@ -6,10 +6,11 @@ enabled =
|
||||
if Rails.configuration.respond_to?(:enable_anon_caching)
|
||||
Rails.configuration.enable_anon_caching
|
||||
else
|
||||
Rails.env.production?
|
||||
Rails.env.production? || Rails.env.test?
|
||||
end
|
||||
|
||||
if !ENV["DISCOURSE_DISABLE_ANON_CACHE"] && enabled
|
||||
# in an ideal world this is position 0, but mobile detection uses ... session and request and params
|
||||
Rails.configuration.middleware.insert_after ActionDispatch::Flash, Middleware::AnonymousCache
|
||||
Rails.configuration.middleware.insert_after Middleware::GtmScriptNonceInjector,
|
||||
Middleware::AnonymousCache
|
||||
end
|
||||
|
@ -85,7 +85,6 @@ class ContentSecurityPolicy
|
||||
end
|
||||
if SiteSetting.gtm_container_id.present?
|
||||
sources << "https://www.googletagmanager.com/gtm.js"
|
||||
sources << "'nonce-#{ApplicationHelper.google_tag_manager_nonce}'"
|
||||
end
|
||||
|
||||
sources << "'#{SplashScreenHelper.fingerprint}'" if SiteSetting.splash_screen
|
||||
|
@ -43,6 +43,21 @@ module Middleware
|
||||
env["ANON_CACHE_DURATION"] = duration
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
# This gives us an API to insert anonymous cache segments
|
||||
class Helper
|
||||
RACK_SESSION = "rack.session"
|
||||
@ -232,7 +247,10 @@ module Middleware
|
||||
end
|
||||
|
||||
def cacheable?
|
||||
!!(!has_auth_cookie? && get? && no_cache_bypass)
|
||||
!!(
|
||||
GlobalSetting.anon_cache_store_threshold > 0 && !has_auth_cookie? && get? &&
|
||||
no_cache_bypass
|
||||
)
|
||||
end
|
||||
|
||||
def compress(val)
|
||||
@ -326,6 +344,8 @@ module Middleware
|
||||
PAYLOAD_INVALID_REQUEST_METHODS = %w[GET HEAD]
|
||||
|
||||
def call(env)
|
||||
return @app.call(env) if defined?(@@disabled) && @@disabled
|
||||
|
||||
if PAYLOAD_INVALID_REQUEST_METHODS.include?(env[Rack::REQUEST_METHOD]) &&
|
||||
env[Rack::RACK_INPUT].size > 0
|
||||
return 413, { "Cache-Control" => "private, max-age=0, must-revalidate" }, []
|
||||
|
26
lib/middleware/gtm_script_nonce_injector.rb
Normal file
26
lib/middleware/gtm_script_nonce_injector.rb
Normal file
@ -0,0 +1,26 @@
|
||||
# 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
|
@ -104,7 +104,9 @@ RSpec.describe ContentSecurityPolicy do
|
||||
|
||||
script_srcs = parse(policy)["script-src"]
|
||||
expect(script_srcs).to include("https://www.googletagmanager.com/gtm.js")
|
||||
expect(script_srcs.to_s).to include("nonce-")
|
||||
# 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-")
|
||||
end
|
||||
|
||||
it "allowlists CDN assets when integrated" do
|
||||
|
@ -3,6 +3,8 @@
|
||||
RSpec.describe Middleware::AnonymousCache do
|
||||
let(:middleware) { Middleware::AnonymousCache.new(lambda { |_| [200, {}, []] }) }
|
||||
|
||||
before { Middleware::AnonymousCache.enable_anon_cache }
|
||||
|
||||
def env(opts = {})
|
||||
create_request_env(path: opts.delete(:path) || "http://test.com/path?bla=1").merge(opts)
|
||||
end
|
||||
|
@ -675,6 +675,8 @@ RSpec.describe Middleware::RequestTracker do
|
||||
after { Middleware::RequestTracker.unregister_detailed_request_logger(logger) }
|
||||
|
||||
it "can report data from anon cache" do
|
||||
Middleware::AnonymousCache.enable_anon_cache
|
||||
|
||||
cache = Middleware::AnonymousCache.new(app([200, {}, ["i am a thing"]]))
|
||||
tracker = Middleware::RequestTracker.new(cache)
|
||||
|
||||
|
@ -149,6 +149,8 @@ module TestSetup
|
||||
Bookmark.reset_bookmarkables
|
||||
|
||||
OmniAuth.config.test_mode = false
|
||||
|
||||
Middleware::AnonymousCache.disable_anon_cache
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -637,15 +637,67 @@ RSpec.describe ApplicationController do
|
||||
|
||||
it "when GTM is enabled it adds the same nonce to the policy and the GTM tag" do
|
||||
SiteSetting.content_security_policy = true
|
||||
SiteSetting.content_security_policy_report_only = true
|
||||
SiteSetting.gtm_container_id = "GTM-ABCDEF"
|
||||
|
||||
get "/latest"
|
||||
nonce = ApplicationHelper.google_tag_manager_nonce
|
||||
expect(response.headers).to include("Content-Security-Policy")
|
||||
|
||||
script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
|
||||
expect(script_src.to_s).to include(nonce)
|
||||
expect(response.body).to include(nonce)
|
||||
report_only_script_src =
|
||||
parse(response.headers["Content-Security-Policy-Report-Only"])["script-src"]
|
||||
|
||||
nonce = extract_nonce_from_script_src(script_src)
|
||||
report_only_nonce = extract_nonce_from_script_src(report_only_script_src)
|
||||
|
||||
expect(nonce).to eq(report_only_nonce)
|
||||
|
||||
gtm_meta_tag = Nokogiri::HTML5.fragment(response.body).css("#data-google-tag-manager").first
|
||||
expect(gtm_meta_tag["data-nonce"]).to eq(nonce)
|
||||
end
|
||||
|
||||
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 = true
|
||||
SiteSetting.content_security_policy_report_only = true
|
||||
SiteSetting.gtm_container_id = "GTM-ABCDEF"
|
||||
|
||||
get "/latest"
|
||||
|
||||
expect(response.headers["X-Discourse-Cached"]).to eq("store")
|
||||
expect(response.headers).not_to include("Discourse-GTM-Nonce-Placeholder")
|
||||
|
||||
script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
|
||||
report_only_script_src =
|
||||
parse(response.headers["Content-Security-Policy-Report-Only"])["script-src"]
|
||||
|
||||
first_nonce = extract_nonce_from_script_src(script_src)
|
||||
first_report_only_nonce = extract_nonce_from_script_src(report_only_script_src)
|
||||
|
||||
expect(first_nonce).to eq(first_report_only_nonce)
|
||||
|
||||
gtm_meta_tag = Nokogiri::HTML5.fragment(response.body).css("#data-google-tag-manager").first
|
||||
expect(gtm_meta_tag["data-nonce"]).to eq(first_nonce)
|
||||
|
||||
get "/latest"
|
||||
|
||||
expect(response.headers["X-Discourse-Cached"]).to eq("true")
|
||||
expect(response.headers).not_to include("Discourse-GTM-Nonce-Placeholder")
|
||||
|
||||
script_src = parse(response.headers["Content-Security-Policy"])["script-src"]
|
||||
report_only_script_src =
|
||||
parse(response.headers["Content-Security-Policy-Report-Only"])["script-src"]
|
||||
|
||||
second_nonce = extract_nonce_from_script_src(script_src)
|
||||
second_report_only_nonce = extract_nonce_from_script_src(report_only_script_src)
|
||||
|
||||
expect(second_nonce).to eq(second_report_only_nonce)
|
||||
|
||||
expect(first_nonce).not_to eq(second_nonce)
|
||||
gtm_meta_tag = Nokogiri::HTML5.fragment(response.body).css("#data-google-tag-manager").first
|
||||
expect(gtm_meta_tag["data-nonce"]).to eq(second_nonce)
|
||||
end
|
||||
|
||||
it "when splash screen is enabled it adds the fingerprint to the policy" do
|
||||
@ -670,6 +722,12 @@ RSpec.describe ApplicationController do
|
||||
end
|
||||
.to_h
|
||||
end
|
||||
|
||||
def extract_nonce_from_script_src(script_src)
|
||||
nonce = script_src.find { |src| src.match?(/\A'nonce-\h{32}'\z/) }[-33...-1]
|
||||
expect(nonce).to be_present
|
||||
nonce
|
||||
end
|
||||
end
|
||||
|
||||
it "can respond to a request with */* accept header" do
|
||||
|
Loading…
x
Reference in New Issue
Block a user