diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index ae225e7c..c80967b0 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -129,6 +129,7 @@ def minify_source_list(directive, source_list) else source_list = populate_nonces(directive, source_list) source_list = reject_all_values_if_none(source_list) + source_list = normalize_uri_paths(source_list) unless directive == REPORT_URI || @preserve_schemes source_list = strip_source_schemes(source_list) @@ -151,6 +152,26 @@ def reject_all_values_if_none(source_list) end end + def normalize_uri_paths(source_list) + source_list.map do |source| + # Normalize domains ending in a single / as without omitting the slash accomplishes the same. + # https://www.w3.org/TR/CSP3/#match-paths ยง 6.6.2.10 Step 2 + begin + uri = URI(source) + if uri.path == "/" + next source.chomp("/") + end + rescue URI::InvalidURIError + end + + if source.chomp("/").include?("/") + source + else + source.chomp("/") + end + end + end + # Private: append a nonce to the script/style directories if script_nonce # or style_nonce are provided. def populate_nonces(directive, source_list) diff --git a/lib/secure_headers/middleware.rb b/lib/secure_headers/middleware.rb index e1c07c5b..c579f69a 100644 --- a/lib/secure_headers/middleware.rb +++ b/lib/secure_headers/middleware.rb @@ -9,6 +9,7 @@ def initialize(app) def call(env) req = Rack::Request.new(env) status, headers, response = @app.call(env) + headers = Rack::Headers[headers] config = SecureHeaders.config_for(req) flag_cookies!(headers, override_secure(env, config.cookies)) unless config.cookies == OPT_OUT @@ -20,14 +21,12 @@ def call(env) # inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194 def flag_cookies!(headers, config) - if cookies = headers["Set-Cookie"] - # Support Rails 2.3 / Rack 1.1 arrays as headers - cookies = cookies.split("\n") unless cookies.is_a?(Array) + cookies = headers["Set-Cookie"] + return unless cookies - headers["Set-Cookie"] = cookies.map do |cookie| - SecureHeaders::Cookie.new(cookie, config).to_s - end.join("\n") - end + cookies_array = cookies.is_a?(Array) ? cookies : cookies.split("\n") + secured_cookies = cookies_array.map { |cookie| SecureHeaders::Cookie.new(cookie, config).to_s } + headers["Set-Cookie"] = cookies.is_a?(Array) ? secured_cookies : secured_cookies.join("\n") end # disable Secure cookies for non-https requests diff --git a/lib/secure_headers/railtie.rb b/lib/secure_headers/railtie.rb index ba255acc..64f9eec9 100644 --- a/lib/secure_headers/railtie.rb +++ b/lib/secure_headers/railtie.rb @@ -22,9 +22,12 @@ class Railtie < Rails::Railtie ActiveSupport.on_load(:action_controller) do include SecureHeaders - unless Rails.application.config.action_dispatch.default_headers.nil? - conflicting_headers.each do |header| - Rails.application.config.action_dispatch.default_headers.delete(header) + default_headers = Rails.application.config.action_dispatch.default_headers + unless default_headers.nil? + default_headers.each_key do |header| + if conflicting_headers.include?(header.downcase) + default_headers.delete(header) + end end end end diff --git a/lib/secure_headers/task_helper.rb b/lib/secure_headers/task_helper.rb new file mode 100644 index 00000000..fa4ba971 --- /dev/null +++ b/lib/secure_headers/task_helper.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module SecureHeaders + module TaskHelper + include SecureHeaders::HashHelper + + INLINE_SCRIPT_REGEX = /()(.*?)<\/script>/mx + INLINE_STYLE_REGEX = /(]*>)(.*?)<\/style>/mx + INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx + INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx + + def generate_inline_script_hashes(filename) + hashes = [] + + hashes.concat find_inline_content(filename, INLINE_SCRIPT_REGEX, false) + hashes.concat find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, true) + + hashes + end + + def generate_inline_style_hashes(filename) + hashes = [] + + hashes.concat find_inline_content(filename, INLINE_STYLE_REGEX, false) + hashes.concat find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, true) + + hashes + end + + def dynamic_content?(filename, inline_script) + !!( + (is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) || + (is_erb?(filename) && inline_script =~ /<%.*%>/) + ) + end + + private + + def find_inline_content(filename, regex, strip_trailing_whitespace) + hashes = [] + file = File.read(filename) + file.scan(regex) do # TODO don't use gsub + inline_script = Regexp.last_match.captures.last + inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace + if dynamic_content?(filename, inline_script) + puts "Looks like there's some dynamic content inside of a tag :-/" + puts "That pretty much means the hash value will never match." + puts "Code: " + inline_script + puts "=" * 20 + end + + hashes << hash_source(inline_script) + end + hashes + end + + def is_erb?(filename) + filename =~ /\.erb\Z/ + end + + def is_mustache?(filename) + filename =~ /\.mustache\Z/ + end + end +end diff --git a/lib/secure_headers/view_helper.rb b/lib/secure_headers/view_helper.rb index 7ed57311..d1ad8c60 100644 --- a/lib/secure_headers/view_helper.rb +++ b/lib/secure_headers/view_helper.rb @@ -65,8 +65,11 @@ def nonced_stylesheet_pack_tag(*args, &block) # Public: use the content security policy nonce for this request directly. # Instructs secure_headers to append a nonce to style/script-src directives. # + # type - (optional) The type of nonce to generate (:script or :style). + # Defaults to :script to match Rails' content_security_policy_nonce behavior. + # # Returns a non-html-safe nonce value. - def _content_security_policy_nonce(type) + def _content_security_policy_nonce(type = :script) case type when :script SecureHeaders.content_security_policy_script_nonce(@_request) diff --git a/lib/tasks/tasks.rake b/lib/tasks/tasks.rake index cb078246..09237bb4 100644 --- a/lib/tasks/tasks.rake +++ b/lib/tasks/tasks.rake @@ -1,58 +1,8 @@ # frozen_string_literal: true -INLINE_SCRIPT_REGEX = /()(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX -INLINE_STYLE_REGEX = /(]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX -INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX -INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX +require "secure_headers/task_helper" namespace :secure_headers do - include SecureHeaders::HashHelper - - def is_erb?(filename) - filename =~ /\.erb\Z/ - end - - def is_mustache?(filename) - filename =~ /\.mustache\Z/ - end - - def dynamic_content?(filename, inline_script) - (is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) || - (is_erb?(filename) && inline_script =~ /<%.*%>/) - end - - def find_inline_content(filename, regex, hashes, strip_trailing_whitespace) - file = File.read(filename) - file.scan(regex) do # TODO don't use gsub - inline_script = Regexp.last_match.captures.last - inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace - if dynamic_content?(filename, inline_script) - puts "Looks like there's some dynamic content inside of a tag :-/" - puts "That pretty much means the hash value will never match." - puts "Code: " + inline_script - puts "=" * 20 - end - - hashes << hash_source(inline_script) - end - end - - def generate_inline_script_hashes(filename) - hashes = [] - - find_inline_content(filename, INLINE_SCRIPT_REGEX, hashes, false) - find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, hashes, true) - - hashes - end - - def generate_inline_style_hashes(filename) - hashes = [] - - find_inline_content(filename, INLINE_STYLE_REGEX, hashes, false) - find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, hashes, true) - - hashes - end + include SecureHeaders::TaskHelper desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" task :generate_hashes do |t, args| @@ -77,6 +27,7 @@ namespace :secure_headers do file.write(script_hashes.to_yaml) end - puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" + file_count = (script_hashes["scripts"].keys + script_hashes["styles"].keys).uniq.count + puts "Script and style hashes from #{file_count} files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" end end diff --git a/spec/lib/secure_headers/headers/content_security_policy_spec.rb b/spec/lib/secure_headers/headers/content_security_policy_spec.rb index 37cb62a7..b6ad1c67 100644 --- a/spec/lib/secure_headers/headers/content_security_policy_spec.rb +++ b/spec/lib/secure_headers/headers/content_security_policy_spec.rb @@ -48,12 +48,20 @@ module SecureHeaders expect(csp.value).to eq("default-src * 'unsafe-inline' 'unsafe-eval' data: blob:") end + it "normalizes source expressions that end with a trailing /" do + config = { + default_src: %w(a.example.org/ b.example.com/ wss://c.example.com/ c.example.net/foo/ b.example.co/bar wss://b.example.co/) + } + csp = ContentSecurityPolicy.new(config) + expect(csp.value).to eq("default-src a.example.org b.example.com wss://c.example.com c.example.net/foo/ b.example.co/bar wss://b.example.co") + end + it "does not minify source expressions based on overlapping wildcards" do config = { - default_src: %w(a.example.org b.example.org *.example.org https://*.example.org) + default_src: %w(a.example.org b.example.org *.example.org https://*.example.org c.example.org/) } csp = ContentSecurityPolicy.new(config) - expect(csp.value).to eq("default-src a.example.org b.example.org *.example.org") + expect(csp.value).to eq("default-src a.example.org b.example.org *.example.org c.example.org") end it "removes http/s schemes from hosts" do diff --git a/spec/lib/secure_headers/middleware_spec.rb b/spec/lib/secure_headers/middleware_spec.rb index b3925f85..1dedbc37 100644 --- a/spec/lib/secure_headers/middleware_spec.rb +++ b/spec/lib/secure_headers/middleware_spec.rb @@ -83,9 +83,9 @@ module SecureHeaders end it "flags cookies with a combination of SameSite configurations" do - cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => ["_session=foobar", "_guest=true"]), "app"] }) + cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => "_session=foobar\n_guest=true"), "app"] }) - Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT} } + Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT } } request = Rack::Request.new("HTTPS" => "on") _, env = cookie_middleware.call request.env @@ -93,6 +93,16 @@ module SecureHeaders expect(env["Set-Cookie"]).to match("_guest=true; SameSite=Lax") end + it "keeps cookies as array after flagging if they are already an array" do + cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => ["_session=foobar", "_guest=true"]), "app"] }) + + Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT } } + request = Rack::Request.new("HTTPS" => "on") + _, env = cookie_middleware.call request.env + + expect(env["Set-Cookie"]).to match_array(["_session=foobar; SameSite=Strict", "_guest=true; SameSite=Lax"]) + end + it "disables secure cookies for non-https requests" do Configuration.default { |config| config.cookies = { secure: true, httponly: OPT_OUT, samesite: OPT_OUT } } diff --git a/spec/lib/secure_headers/task_helper_spec.rb b/spec/lib/secure_headers/task_helper_spec.rb new file mode 100644 index 00000000..8be633f5 --- /dev/null +++ b/spec/lib/secure_headers/task_helper_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require "spec_helper" +require "secure_headers/task_helper" + +class TestHelper + include SecureHeaders::TaskHelper +end + +module SecureHeaders + describe TaskHelper do + subject { TestHelper.new } + + let(:template) do + < + + + + <%= hashed_javascript_tag do %> + alert("Using the helper tag!") + <% end %> + <%= hashed_style_tag do %> + p { text-decoration: underline; } + <% end %> + + +

Testing

+ + +EOT + end + + let(:template_unindented) do + < + + + + <%= hashed_javascript_tag do %> + alert("Using the helper tag!") +<% end %> + <%= hashed_style_tag do %> + p { text-decoration: underline; } +<% end %> + + +

Testing

+ + +EOT + end + + describe "#generate_inline_script_hashes" do + let(:expected_hashes) do + [ + "'sha256-EE/znQZ7BcfM3LbsqxUc5JlCtE760Pc2RV18tW90DCo='", + "'sha256-64ro9ciexeO5JqSZcAnhmJL4wbzCrpsZJLWl5H6mrkA='" + ] + end + + it "returns an array of found script hashes" do + Tempfile.create("script") do |f| + f.write template + f.flush + expect(subject.generate_inline_script_hashes(f.path)).to eq expected_hashes + end + end + it "returns the same array no matter the indentation of helper end tags" do + Tempfile.create("script") do |f| + f.write template_unindented + f.flush + expect(subject.generate_inline_script_hashes(f.path)).to eq expected_hashes + end + end + end + + describe "#generate_inline_style_hashes" do + let(:expected_hashes) do + [ + "'sha256-pckGv9YvNcB5xy+Y4fbqhyo+ib850wyiuWeNbZvLi00='", + "'sha256-d374zYt40cLTr8J7Cvm/l4oDY4P9UJ8TWhYG0iEglU4='" + ] + end + + it "returns an array of found style hashes" do + Tempfile.create("style") do |f| + f.write template + f.flush + expect(subject.generate_inline_style_hashes(f.path)).to eq expected_hashes + end + end + it "returns the same array no matter the indentation of helper end tags" do + Tempfile.create("style") do |f| + f.write template_unindented + f.flush + expect(subject.generate_inline_style_hashes(f.path)).to eq expected_hashes + end + end + end + + describe "#dynamic_content?" do + context "mustache file" do + it "finds mustache templating tokens" do + expect(subject.dynamic_content?("file.mustache", "var test = {{ dynamic_value }};")).to be true + end + + it "returns false when not finding any templating tokens" do + expect(subject.dynamic_content?("file.mustache", "var test = 'static value';")).to be false + end + end + + context "erb file" do + it "finds erb templating tokens" do + expect(subject.dynamic_content?("file.erb", "var test = <%= dynamic_value %>;")).to be true + end + + it "returns false when not finding any templating tokens" do + expect(subject.dynamic_content?("file.erb", "var test = 'static value';")).to be false + end + end + end + end +end diff --git a/spec/lib/secure_headers/view_helpers_spec.rb b/spec/lib/secure_headers/view_helpers_spec.rb index 2a7f56ed..210ea04c 100644 --- a/spec/lib/secure_headers/view_helpers_spec.rb +++ b/spec/lib/secure_headers/view_helpers_spec.rb @@ -188,5 +188,60 @@ module SecureHeaders expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).not_to match(/rails-nonce/) end end + + it "supports calling content_security_policy_nonce without parameters (Rails compatibility)" do + allow(SecureRandom).to receive(:base64).and_return("xyz789") + + # Create a test class that simulates Rails-compatible usage + # where content_security_policy_nonce is called without any parameters + test_class = Class.new(Message) do + def self.template + <<-TEMPLATE + +TEMPLATE + end + end + + message = test_class.new(request) + result = message.result + + # The nonce should be included in the rendered output + expect(result).to include('nonce="xyz789"') + + # Call middleware to generate headers + _, env = middleware.call request.env + + # The nonce should be added to script-src in the CSP header (default behavior) + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'nonce-xyz789'/) + end + + it "supports calling content_security_policy_nonce with :style parameter" do + allow(SecureRandom).to receive(:base64).and_return("style123") + + # Create a test class that calls content_security_policy_nonce with :style + test_class = Class.new(Message) do + def self.template + <<-TEMPLATE + +TEMPLATE + end + end + + message = test_class.new(request) + result = message.result + + # The nonce should be included in the rendered output + expect(result).to include('nonce="style123"') + + # Call middleware to generate headers + _, env = middleware.call request.env + + # The nonce should be added to style-src in the CSP header + expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'nonce-style123'/) + end end end