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.
@@ -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
@@ -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
@@ -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.1.0"
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, :secure_cookies].each do |key|
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 do |config|
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 "cookies should be flagged" do
42
- it "flags cookies as secure" do
43
- Configuration.default { |config| config.secure_cookies = true }
44
- request = Rack::MockRequest.new(cookie_middleware)
45
- response = request.get '/'
46
- expect(response.headers['Set-Cookie']).to match(Middleware::SECURE_COOKIE_REGEXP)
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 should not be flagged" do
51
- it "does not flags cookies as secure" do
52
- Configuration.default { |config| config.secure_cookies = false }
53
- request = Rack::MockRequest.new(cookie_middleware)
54
- response = request.get '/'
55
- expect(response.headers['Set-Cookie']).not_to match(Middleware::SECURE_COOKIE_REGEXP)
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