Skip to content
Draft
21 changes: 21 additions & 0 deletions lib/secure_headers/headers/content_security_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
13 changes: 6 additions & 7 deletions lib/secure_headers/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions lib/secure_headers/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions lib/secure_headers/task_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module SecureHeaders
module TaskHelper
include SecureHeaders::HashHelper

INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/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
5 changes: 4 additions & 1 deletion lib/secure_headers/view_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 4 additions & 53 deletions lib/tasks/tasks.rake
Original file line number Diff line number Diff line change
@@ -1,58 +1,8 @@
# frozen_string_literal: true
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/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|
Expand All @@ -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
12 changes: 10 additions & 2 deletions spec/lib/secure_headers/headers/content_security_policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions spec/lib/secure_headers/middleware_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,26 @@ 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

expect(env["Set-Cookie"]).to match("_session=foobar; SameSite=Strict")
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 } }

Expand Down
Loading