secure_headers 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -0
- data/README.md +145 -19
- data/lib/secure_headers/configuration.rb +28 -4
- data/lib/secure_headers/hash_helper.rb +10 -0
- data/lib/secure_headers/headers/cookie.rb +126 -0
- data/lib/secure_headers/headers/policy_management.rb +1 -0
- data/lib/secure_headers/middleware.rb +23 -9
- data/lib/secure_headers/railtie.rb +4 -0
- data/lib/secure_headers/utils/cookies_config.rb +94 -0
- data/lib/secure_headers/view_helper.rb +64 -0
- data/lib/secure_headers.rb +2 -0
- data/lib/tasks/tasks.rake +81 -0
- data/secure_headers.gemspec +1 -1
- data/spec/lib/secure_headers/configuration_spec.rb +17 -1
- data/spec/lib/secure_headers/headers/cookie_spec.rb +164 -0
- data/spec/lib/secure_headers/middleware_spec.rb +48 -15
- data/spec/lib/secure_headers/view_helpers_spec.rb +125 -0
- data/spec/lib/secure_headers_spec.rb +19 -1
- data/spec/spec_helper.rb +12 -0
- data/upgrading-to-3-0.md +1 -0
- metadata +10 -2
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module SecureHeaders
|
|
2
|
+
class CookiesConfig
|
|
3
|
+
|
|
4
|
+
attr_reader :config
|
|
5
|
+
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def validate!
|
|
11
|
+
return if config.nil? || config == SecureHeaders::OPT_OUT
|
|
12
|
+
|
|
13
|
+
validate_config!
|
|
14
|
+
validate_secure_config! if config[:secure]
|
|
15
|
+
validate_httponly_config! if config[:httponly]
|
|
16
|
+
validate_samesite_config! if config[:samesite]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_config!
|
|
22
|
+
raise CookiesConfigError.new("config must be a hash.") unless is_hash?(config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_secure_config!
|
|
26
|
+
validate_hash_or_boolean!(:secure)
|
|
27
|
+
validate_exclusive_use_of_hash_constraints!(config[:secure], :secure)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_httponly_config!
|
|
31
|
+
validate_hash_or_boolean!(:httponly)
|
|
32
|
+
validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_samesite_config!
|
|
36
|
+
raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite])
|
|
37
|
+
|
|
38
|
+
validate_samesite_boolean_config!
|
|
39
|
+
validate_samesite_hash_config!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# when configuring with booleans, only one enforcement is permitted
|
|
43
|
+
def validate_samesite_boolean_config!
|
|
44
|
+
if config[:samesite].key?(:lax) && config[:samesite][:lax].is_a?(TrueClass) && config[:samesite].key?(:strict)
|
|
45
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
|
|
46
|
+
elsif config[:samesite].key?(:strict) && config[:samesite][:strict].is_a?(TrueClass) && config[:samesite].key?(:lax)
|
|
47
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_samesite_hash_config!
|
|
52
|
+
# validate Hash-based samesite configuration
|
|
53
|
+
if is_hash?(config[:samesite][:lax])
|
|
54
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], 'samesite lax')
|
|
55
|
+
|
|
56
|
+
if is_hash?(config[:samesite][:strict])
|
|
57
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], 'samesite strict')
|
|
58
|
+
validate_exclusive_use_of_samesite_enforcement!(:only)
|
|
59
|
+
validate_exclusive_use_of_samesite_enforcement!(:except)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_hash_or_boolean!(attribute)
|
|
65
|
+
if !(is_hash?(config[attribute]) || is_boolean?(config[attribute]))
|
|
66
|
+
raise CookiesConfigError.new("#{attribute} cookie config must be a hash or boolean")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# validate exclusive use of only or except but not both at the same time
|
|
71
|
+
def validate_exclusive_use_of_hash_constraints!(conf, attribute)
|
|
72
|
+
return unless is_hash?(conf)
|
|
73
|
+
|
|
74
|
+
if conf.key?(:only) && conf.key?(:except)
|
|
75
|
+
raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# validate exclusivity of only and except members within strict and lax
|
|
80
|
+
def validate_exclusive_use_of_samesite_enforcement!(attribute)
|
|
81
|
+
if (intersection = (config[:samesite][:lax].fetch(attribute, []) & config[:samesite][:strict].fetch(attribute, []))).any?
|
|
82
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, cookie(s) #{intersection.join(', ')} cannot be enforced as lax and strict")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def is_hash?(obj)
|
|
87
|
+
obj && obj.is_a?(Hash)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def is_boolean?(obj)
|
|
91
|
+
obj && (obj.is_a?(TrueClass) || obj.is_a?(FalseClass))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module SecureHeaders
|
|
2
2
|
module ViewHelpers
|
|
3
|
+
include SecureHeaders::HashHelper
|
|
4
|
+
SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
|
|
5
|
+
|
|
6
|
+
class UnexpectedHashedScriptException < StandardError; end
|
|
7
|
+
|
|
3
8
|
# Public: create a style tag using the content security policy nonce.
|
|
4
9
|
# Instructs secure_headers to append a nonce to style/script-src directives.
|
|
5
10
|
#
|
|
@@ -29,8 +34,67 @@ module SecureHeaders
|
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
##
|
|
38
|
+
# Checks to see if the hashed code is expected and adds the hash source
|
|
39
|
+
# value to the current CSP.
|
|
40
|
+
#
|
|
41
|
+
# By default, in development/test/etc. an exception will be raised.
|
|
42
|
+
def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block)
|
|
43
|
+
hashed_tag(
|
|
44
|
+
:script,
|
|
45
|
+
:script_src,
|
|
46
|
+
Configuration.instance_variable_get(:@script_hashes),
|
|
47
|
+
raise_error_on_unrecognized_hash,
|
|
48
|
+
block
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block)
|
|
53
|
+
hashed_tag(
|
|
54
|
+
:style,
|
|
55
|
+
:style_src,
|
|
56
|
+
Configuration.instance_variable_get(:@style_hashes),
|
|
57
|
+
raise_error_on_unrecognized_hash,
|
|
58
|
+
block
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
32
62
|
private
|
|
33
63
|
|
|
64
|
+
def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block)
|
|
65
|
+
if raise_error_on_unrecognized_hash.nil?
|
|
66
|
+
raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
content = capture(&block)
|
|
70
|
+
file_path = File.join('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')
|
|
71
|
+
|
|
72
|
+
if raise_error_on_unrecognized_hash
|
|
73
|
+
hash_value = hash_source(content)
|
|
74
|
+
message = unexpected_hash_error_message(file_path, content, hash_value)
|
|
75
|
+
|
|
76
|
+
if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value)
|
|
77
|
+
raise UnexpectedHashedScriptException.new(message)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path])
|
|
82
|
+
|
|
83
|
+
content_tag type, content
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def unexpected_hash_error_message(file_path, content, hash_value)
|
|
87
|
+
<<-EOF
|
|
88
|
+
\n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
|
|
89
|
+
#{content}
|
|
90
|
+
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/script_hashes.yml:***
|
|
91
|
+
#{file_path}:
|
|
92
|
+
- #{hash_value}\n\n
|
|
93
|
+
NOTE: dynamic javascript is not supported using script hash integration
|
|
94
|
+
on purpose. It defeats the point of using it in the first place.
|
|
95
|
+
EOF
|
|
96
|
+
end
|
|
97
|
+
|
|
34
98
|
def nonced_tag(type, content_or_options, block)
|
|
35
99
|
options = {}
|
|
36
100
|
content = if block
|
data/lib/secure_headers.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require "secure_headers/configuration"
|
|
2
|
+
require "secure_headers/hash_helper"
|
|
3
|
+
require "secure_headers/headers/cookie"
|
|
2
4
|
require "secure_headers/headers/public_key_pins"
|
|
3
5
|
require "secure_headers/headers/content_security_policy"
|
|
4
6
|
require "secure_headers/headers/x_frame_options"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
|
|
2
|
+
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx
|
|
3
|
+
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
|
|
4
|
+
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
|
|
5
|
+
|
|
6
|
+
namespace :secure_headers do
|
|
7
|
+
include SecureHeaders::HashHelper
|
|
8
|
+
|
|
9
|
+
def is_erb?(filename)
|
|
10
|
+
filename =~ /\.erb\Z/
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def is_mustache?(filename)
|
|
14
|
+
filename =~ /\.mustache\Z/
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dynamic_content?(filename, inline_script)
|
|
18
|
+
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
|
|
19
|
+
(is_erb?(filename) && inline_script =~ /<%.*%>/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find_inline_content(filename, regex, hashes)
|
|
23
|
+
file = File.read(filename)
|
|
24
|
+
file.scan(regex) do # TODO don't use gsub
|
|
25
|
+
inline_script = Regexp.last_match.captures.last
|
|
26
|
+
if dynamic_content?(filename, inline_script)
|
|
27
|
+
puts "Looks like there's some dynamic content inside of a tag :-/"
|
|
28
|
+
puts "That pretty much means the hash value will never match."
|
|
29
|
+
puts "Code: " + inline_script
|
|
30
|
+
puts "=" * 20
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
hashes << hash_source(inline_script)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def generate_inline_script_hashes(filename)
|
|
38
|
+
hashes = []
|
|
39
|
+
|
|
40
|
+
[INLINE_SCRIPT_REGEX, INLINE_HASH_SCRIPT_HELPER_REGEX].each do |regex|
|
|
41
|
+
find_inline_content(filename, regex, hashes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
hashes
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def generate_inline_style_hashes(filename)
|
|
48
|
+
hashes = []
|
|
49
|
+
|
|
50
|
+
[INLINE_STYLE_REGEX, INLINE_HASH_STYLE_HELPER_REGEX].each do |regex|
|
|
51
|
+
find_inline_content(filename, regex, hashes)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
hashes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
task :generate_hashes do |t, args|
|
|
58
|
+
script_hashes = {
|
|
59
|
+
"scripts" => {},
|
|
60
|
+
"styles" => {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename|
|
|
64
|
+
hashes = generate_inline_script_hashes(filename)
|
|
65
|
+
if hashes.any?
|
|
66
|
+
script_hashes["scripts"][filename] = hashes
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
hashes = generate_inline_style_hashes(filename)
|
|
70
|
+
if hashes.any?
|
|
71
|
+
script_hashes["styles"][filename] = hashes
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
File.open(SecureHeaders::Configuration::HASH_CONFIG_FILE, 'w') do |file|
|
|
76
|
+
file.write(script_hashes.to_yaml)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
80
|
+
end
|
|
81
|
+
end
|
data/secure_headers.gemspec
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
2
|
Gem::Specification.new do |gem|
|
|
3
3
|
gem.name = "secure_headers"
|
|
4
|
-
gem.version = "3.
|
|
4
|
+
gem.version = "3.2.0"
|
|
5
5
|
gem.authors = ["Neil Matatall"]
|
|
6
6
|
gem.email = ["neil.matatall@gmail.com"]
|
|
7
7
|
gem.description = 'Security related headers all in one gem.'
|
|
@@ -36,11 +36,19 @@ module SecureHeaders
|
|
|
36
36
|
|
|
37
37
|
config = Configuration.get(:test_override)
|
|
38
38
|
noop = Configuration.get(Configuration::NOOP_CONFIGURATION)
|
|
39
|
-
[:csp, :dynamic_csp, :
|
|
39
|
+
[:csp, :dynamic_csp, :cookies].each do |key|
|
|
40
40
|
expect(config.send(key)).to eq(noop.send(key)), "Value not copied: #{key}."
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
it "regenerates cached headers when building an override" do
|
|
45
|
+
Configuration.override(:test_override) do |config|
|
|
46
|
+
config.x_content_type_options = OPT_OUT
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
expect(Configuration.get.cached_headers).to_not eq(Configuration.get(:test_override).cached_headers)
|
|
50
|
+
end
|
|
51
|
+
|
|
44
52
|
it "stores an override of the global config" do
|
|
45
53
|
Configuration.override(:test_override) do |config|
|
|
46
54
|
config.x_frame_options = "DENY"
|
|
@@ -74,5 +82,13 @@ module SecureHeaders
|
|
|
74
82
|
override_config = Configuration.get(:second_override)
|
|
75
83
|
expect(override_config.csp).to eq(default_src: %w('self'), script_src: %w(example.org))
|
|
76
84
|
end
|
|
85
|
+
|
|
86
|
+
it "deprecates the secure_cookies configuration" do
|
|
87
|
+
expect(Kernel).to receive(:warn).with(/\[DEPRECATION\]/)
|
|
88
|
+
|
|
89
|
+
Configuration.default do |config|
|
|
90
|
+
config.secure_cookies = true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
77
93
|
end
|
|
78
94
|
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module SecureHeaders
|
|
4
|
+
describe Cookie do
|
|
5
|
+
let(:raw_cookie) { "_session=thisisatest" }
|
|
6
|
+
|
|
7
|
+
it "does not tamper with cookies when unconfigured" do
|
|
8
|
+
cookie = Cookie.new(raw_cookie, {})
|
|
9
|
+
expect(cookie.to_s).to eq(raw_cookie)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "preserves existing attributes" do
|
|
13
|
+
cookie = Cookie.new("_session=thisisatest; secure", secure: true)
|
|
14
|
+
expect(cookie.to_s).to eq("_session=thisisatest; secure")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "prevents duplicate flagging of attributes" do
|
|
18
|
+
cookie = Cookie.new("_session=thisisatest; secure", secure: true)
|
|
19
|
+
expect(cookie.to_s.scan(/secure/i).count).to eq(1)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
context "Secure cookies" do
|
|
23
|
+
context "when configured with a boolean" do
|
|
24
|
+
it "flags cookies as Secure" do
|
|
25
|
+
cookie = Cookie.new(raw_cookie, secure: true)
|
|
26
|
+
expect(cookie.to_s).to eq("_session=thisisatest; secure")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
context "when configured with a Hash" do
|
|
31
|
+
it "flags cookies as Secure when whitelisted" do
|
|
32
|
+
cookie = Cookie.new(raw_cookie, secure: { only: ["_session"]})
|
|
33
|
+
expect(cookie.to_s).to eq("_session=thisisatest; secure")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "does not flag cookies as Secure when excluded" do
|
|
37
|
+
cookie = Cookie.new(raw_cookie, secure: { except: ["_session"] })
|
|
38
|
+
expect(cookie.to_s).to eq("_session=thisisatest")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context "HttpOnly cookies" do
|
|
44
|
+
context "when configured with a boolean" do
|
|
45
|
+
it "flags cookies as HttpOnly" do
|
|
46
|
+
cookie = Cookie.new(raw_cookie, httponly: true)
|
|
47
|
+
expect(cookie.to_s).to eq("_session=thisisatest; HttpOnly")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
context "when configured with a Hash" do
|
|
52
|
+
it "flags cookies as HttpOnly when whitelisted" do
|
|
53
|
+
cookie = Cookie.new(raw_cookie, httponly: { only: ["_session"]})
|
|
54
|
+
expect(cookie.to_s).to eq("_session=thisisatest; HttpOnly")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "does not flag cookies as HttpOnly when excluded" do
|
|
58
|
+
cookie = Cookie.new(raw_cookie, httponly: { except: ["_session"] })
|
|
59
|
+
expect(cookie.to_s).to eq("_session=thisisatest")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context "SameSite cookies" do
|
|
65
|
+
it "flags SameSite=Lax" do
|
|
66
|
+
cookie = Cookie.new(raw_cookie, samesite: { lax: { only: ["_session"] } })
|
|
67
|
+
expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Lax")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "flags SameSite=Lax when configured with a boolean" do
|
|
71
|
+
cookie = Cookie.new(raw_cookie, samesite: { lax: true})
|
|
72
|
+
expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Lax")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "does not flag cookies as SameSite=Lax when excluded" do
|
|
76
|
+
cookie = Cookie.new(raw_cookie, samesite: { lax: { except: ["_session"] } })
|
|
77
|
+
expect(cookie.to_s).to eq("_session=thisisatest")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "flags SameSite=Strict" do
|
|
81
|
+
cookie = Cookie.new(raw_cookie, samesite: { strict: { only: ["_session"] } })
|
|
82
|
+
expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Strict")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "does not flag cookies as SameSite=Strict when excluded" do
|
|
86
|
+
cookie = Cookie.new(raw_cookie, samesite: { strict: { except: ["_session"] } })
|
|
87
|
+
expect(cookie.to_s).to eq("_session=thisisatest")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "flags SameSite=Strict when configured with a boolean" do
|
|
91
|
+
cookie = Cookie.new(raw_cookie, samesite: { strict: true})
|
|
92
|
+
expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Strict")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "flags properly when both lax and strict are configured" do
|
|
96
|
+
raw_cookie = "_session=thisisatest"
|
|
97
|
+
cookie = Cookie.new(raw_cookie, samesite: { strict: { only: ["_session"] }, lax: { only: ["_additional_session"] } })
|
|
98
|
+
expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Strict")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "ignores configuration if the cookie is already flagged" do
|
|
102
|
+
raw_cookie = "_session=thisisatest; SameSite=Strict"
|
|
103
|
+
cookie = Cookie.new(raw_cookie, samesite: { lax: true })
|
|
104
|
+
expect(cookie.to_s).to eq(raw_cookie)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context "with an invalid configuration" do
|
|
110
|
+
it "raises an exception when not configured with a Hash" do
|
|
111
|
+
expect do
|
|
112
|
+
Cookie.validate_config!("configuration")
|
|
113
|
+
end.to raise_error(CookiesConfigError)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "raises an exception when configured without a boolean/Hash" do
|
|
117
|
+
expect do
|
|
118
|
+
Cookie.validate_config!(secure: "true")
|
|
119
|
+
end.to raise_error(CookiesConfigError)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "raises an exception when both only and except filters are provided" do
|
|
123
|
+
expect do
|
|
124
|
+
Cookie.validate_config!(secure: { only: [], except: [] })
|
|
125
|
+
end.to raise_error(CookiesConfigError)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "raises an exception when SameSite is not configured with a Hash" do
|
|
129
|
+
expect do
|
|
130
|
+
Cookie.validate_config!(samesite: true)
|
|
131
|
+
end.to raise_error(CookiesConfigError)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "raises an exception when SameSite lax and strict enforcement modes are configured with booleans" do
|
|
135
|
+
expect do
|
|
136
|
+
Cookie.validate_config!(samesite: { lax: true, strict: true})
|
|
137
|
+
end.to raise_error(CookiesConfigError)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "raises an exception when SameSite lax and strict enforcement modes are configured with booleans" do
|
|
141
|
+
expect do
|
|
142
|
+
Cookie.validate_config!(samesite: { lax: true, strict: { only: ["_anything"] } })
|
|
143
|
+
end.to raise_error(CookiesConfigError)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "raises an exception when both only and except filters are provided to SameSite configurations" do
|
|
147
|
+
expect do
|
|
148
|
+
Cookie.validate_config!(samesite: { lax: { only: ["_anything"], except: ["_anythingelse"] } })
|
|
149
|
+
end.to raise_error(CookiesConfigError)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "raises an exception when both lax and strict only filters are provided to SameSite configurations" do
|
|
153
|
+
expect do
|
|
154
|
+
Cookie.validate_config!(samesite: { lax: { only: ["_anything"] }, strict: { only: ["_anything"] } })
|
|
155
|
+
end.to raise_error(CookiesConfigError)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "raises an exception when both lax and strict only filters are provided to SameSite configurations" do
|
|
159
|
+
expect do
|
|
160
|
+
Cookie.validate_config!(samesite: { lax: { except: ["_anything"] }, strict: { except: ["_anything"] } })
|
|
161
|
+
end.to raise_error(CookiesConfigError)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -10,9 +10,7 @@ module SecureHeaders
|
|
|
10
10
|
|
|
11
11
|
before(:each) do
|
|
12
12
|
reset_config
|
|
13
|
-
Configuration.default
|
|
14
|
-
# use all default provided by the library
|
|
15
|
-
end
|
|
13
|
+
Configuration.default
|
|
16
14
|
end
|
|
17
15
|
|
|
18
16
|
it "sets the headers" do
|
|
@@ -38,21 +36,56 @@ module SecureHeaders
|
|
|
38
36
|
expect(env[CSP::HEADER_NAME]).to match("example.org")
|
|
39
37
|
end
|
|
40
38
|
|
|
41
|
-
context "
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
context "secure_cookies" do
|
|
40
|
+
context "cookies should be flagged" do
|
|
41
|
+
it "flags cookies as secure" do
|
|
42
|
+
capture_warning do
|
|
43
|
+
Configuration.default { |config| config.secure_cookies = true }
|
|
44
|
+
end
|
|
45
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
46
|
+
_, env = cookie_middleware.call request.env
|
|
47
|
+
expect(env['Set-Cookie']).to eq("foo=bar; secure")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
context "cookies should not be flagged" do
|
|
52
|
+
it "does not flags cookies as secure" do
|
|
53
|
+
capture_warning do
|
|
54
|
+
Configuration.default { |config| config.secure_cookies = false }
|
|
55
|
+
end
|
|
56
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
57
|
+
_, env = cookie_middleware.call request.env
|
|
58
|
+
expect(env['Set-Cookie']).to eq("foo=bar")
|
|
59
|
+
end
|
|
47
60
|
end
|
|
48
61
|
end
|
|
49
62
|
|
|
50
|
-
context "cookies
|
|
51
|
-
it "
|
|
52
|
-
Configuration.default { |config| config.
|
|
53
|
-
request = Rack::
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
context "cookies" do
|
|
64
|
+
it "flags cookies from configuration" do
|
|
65
|
+
Configuration.default { |config| config.cookies = { secure: true, httponly: true } }
|
|
66
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
67
|
+
_, env = cookie_middleware.call request.env
|
|
68
|
+
|
|
69
|
+
expect(env['Set-Cookie']).to eq("foo=bar; secure; HttpOnly")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "flags cookies with a combination of SameSite configurations" do
|
|
73
|
+
cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => ["_session=foobar", "_guest=true"]), "app"] })
|
|
74
|
+
|
|
75
|
+
Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } } } }
|
|
76
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
77
|
+
_, env = cookie_middleware.call request.env
|
|
78
|
+
|
|
79
|
+
expect(env['Set-Cookie']).to match("_session=foobar; SameSite=Strict")
|
|
80
|
+
expect(env['Set-Cookie']).to match("_guest=true; SameSite=Lax")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "disables secure cookies for non-https requests" do
|
|
84
|
+
Configuration.default { |config| config.cookies = { secure: true } }
|
|
85
|
+
|
|
86
|
+
request = Rack::Request.new("HTTPS" => "off")
|
|
87
|
+
_, env = cookie_middleware.call request.env
|
|
88
|
+
expect(env['Set-Cookie']).to eq("foo=bar")
|
|
56
89
|
end
|
|
57
90
|
end
|
|
58
91
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "erb"
|
|
3
|
+
|
|
4
|
+
class Message < ERB
|
|
5
|
+
include SecureHeaders::ViewHelpers
|
|
6
|
+
|
|
7
|
+
def self.template
|
|
8
|
+
<<-TEMPLATE
|
|
9
|
+
<% hashed_javascript_tag(raise_error_on_unrecognized_hash = true) do %>
|
|
10
|
+
console.log(1)
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<% hashed_style_tag do %>
|
|
14
|
+
body {
|
|
15
|
+
background-color: black;
|
|
16
|
+
}
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% nonced_javascript_tag do %>
|
|
20
|
+
body {
|
|
21
|
+
console.log(1)
|
|
22
|
+
}
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% nonced_style_tag do %>
|
|
26
|
+
body {
|
|
27
|
+
background-color: black;
|
|
28
|
+
}
|
|
29
|
+
<% end %>
|
|
30
|
+
<%= @name %>
|
|
31
|
+
|
|
32
|
+
TEMPLATE
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(request, options = {})
|
|
36
|
+
@virtual_path = "/asdfs/index"
|
|
37
|
+
@_request = request
|
|
38
|
+
@template = self.class.template
|
|
39
|
+
super(@template)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def capture(*args)
|
|
43
|
+
yield(*args)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def content_tag(type, content = nil, options = nil, &block)
|
|
47
|
+
content = if block_given?
|
|
48
|
+
capture(block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if options.is_a?(Hash)
|
|
52
|
+
options = options.map {|k,v| " #{k}=#{v}"}
|
|
53
|
+
end
|
|
54
|
+
"<#{type}#{options}>#{content}</#{type}>"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def result
|
|
58
|
+
super(binding)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def request
|
|
62
|
+
@_request
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module SecureHeaders
|
|
67
|
+
describe ViewHelpers do
|
|
68
|
+
let(:app) { lambda { |env| [200, env, "app"] } }
|
|
69
|
+
let(:middleware) { Middleware.new(app) }
|
|
70
|
+
let(:request) { Rack::Request.new("HTTP_USER_AGENT" => USER_AGENTS[:chrome]) }
|
|
71
|
+
let(:filename) { "app/views/asdfs/index.html.erb" }
|
|
72
|
+
|
|
73
|
+
before(:all) do
|
|
74
|
+
Configuration.default do |config|
|
|
75
|
+
config.csp[:script_src] = %w('self')
|
|
76
|
+
config.csp[:style_src] = %w('self')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
after(:each) do
|
|
81
|
+
Configuration.instance_variable_set(:@script_hashes, nil)
|
|
82
|
+
Configuration.instance_variable_set(:@style_hashes, nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "raises an error when using hashed content without precomputed hashes" do
|
|
86
|
+
expect {
|
|
87
|
+
Message.new(request).result
|
|
88
|
+
}.to raise_error(ViewHelpers::UnexpectedHashedScriptException)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "raises an error when using hashed content with precomputed hashes, but none for the given file" do
|
|
92
|
+
Configuration.instance_variable_set(:@script_hashes, filename.reverse => ["'sha256-123'"])
|
|
93
|
+
expect {
|
|
94
|
+
Message.new(request).result
|
|
95
|
+
}.to raise_error(ViewHelpers::UnexpectedHashedScriptException)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises an error when using previously unknown hashed content with precomputed hashes for a given file" do
|
|
99
|
+
Configuration.instance_variable_set(:@script_hashes, filename => ["'sha256-123'"])
|
|
100
|
+
expect {
|
|
101
|
+
Message.new(request).result
|
|
102
|
+
}.to raise_error(ViewHelpers::UnexpectedHashedScriptException)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "adds known hash values to the corresponding headers when the helper is used" do
|
|
106
|
+
begin
|
|
107
|
+
allow(SecureRandom).to receive(:base64).and_return("abc123")
|
|
108
|
+
|
|
109
|
+
expected_hash = "sha256-3/URElR9+3lvLIouavYD/vhoICSNKilh15CzI/nKqg8="
|
|
110
|
+
Configuration.instance_variable_set(:@script_hashes, filename => ["'#{expected_hash}'"])
|
|
111
|
+
expected_style_hash = "sha256-7oYK96jHg36D6BM042er4OfBnyUDTG3pH1L8Zso3aGc="
|
|
112
|
+
Configuration.instance_variable_set(:@style_hashes, filename => ["'#{expected_style_hash}'"])
|
|
113
|
+
|
|
114
|
+
# render erb that calls out to helpers.
|
|
115
|
+
Message.new(request).result
|
|
116
|
+
_, env = middleware.call request.env
|
|
117
|
+
|
|
118
|
+
expect(env[CSP::HEADER_NAME]).to match(/script-src[^;]*'#{Regexp.escape(expected_hash)}'/)
|
|
119
|
+
expect(env[CSP::HEADER_NAME]).to match(/script-src[^;]*'nonce-abc123'/)
|
|
120
|
+
expect(env[CSP::HEADER_NAME]).to match(/style-src[^;]*'nonce-abc123'/)
|
|
121
|
+
expect(env[CSP::HEADER_NAME]).to match(/style-src[^;]*'#{Regexp.escape(expected_style_hash)}'/)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|