secure_headers 3.0.3 → 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,126 @@
1
+ require 'cgi'
2
+ require 'secure_headers/utils/cookies_config'
3
+
4
+ module SecureHeaders
5
+ class CookiesConfigError < StandardError; end
6
+ class Cookie
7
+
8
+ class << self
9
+ def validate_config!(config)
10
+ CookiesConfig.new(config).validate!
11
+ end
12
+ end
13
+
14
+ attr_reader :raw_cookie, :config
15
+
16
+ def initialize(cookie, config)
17
+ @raw_cookie = cookie
18
+ @config = config
19
+ @attributes = {
20
+ httponly: nil,
21
+ samesite: nil,
22
+ secure: nil,
23
+ }
24
+
25
+ parse(cookie)
26
+ end
27
+
28
+ def to_s
29
+ @raw_cookie.dup.tap do |c|
30
+ c << "; secure" if secure?
31
+ c << "; HttpOnly" if httponly?
32
+ c << "; #{samesite_cookie}" if samesite?
33
+ end
34
+ end
35
+
36
+ def secure?
37
+ flag_cookie?(:secure) && !already_flagged?(:secure)
38
+ end
39
+
40
+ def httponly?
41
+ flag_cookie?(:httponly) && !already_flagged?(:httponly)
42
+ end
43
+
44
+ def samesite?
45
+ flag_samesite? && !already_flagged?(:samesite)
46
+ end
47
+
48
+ private
49
+
50
+ def parsed_cookie
51
+ @parsed_cookie ||= CGI::Cookie.parse(raw_cookie)
52
+ end
53
+
54
+ def already_flagged?(attribute)
55
+ @attributes[attribute]
56
+ end
57
+
58
+ def flag_cookie?(attribute)
59
+ case config[attribute]
60
+ when TrueClass
61
+ true
62
+ when Hash
63
+ conditionally_flag?(config[attribute])
64
+ else
65
+ false
66
+ end
67
+ end
68
+
69
+ def conditionally_flag?(configuration)
70
+ if(Array(configuration[:only]).any? && (Array(configuration[:only]) & parsed_cookie.keys).any?)
71
+ true
72
+ elsif(Array(configuration[:except]).any? && (Array(configuration[:except]) & parsed_cookie.keys).none?)
73
+ true
74
+ else
75
+ false
76
+ end
77
+ end
78
+
79
+ def samesite_cookie
80
+ if flag_samesite_lax?
81
+ "SameSite=Lax"
82
+ elsif flag_samesite_strict?
83
+ "SameSite=Strict"
84
+ end
85
+ end
86
+
87
+ def flag_samesite?
88
+ flag_samesite_lax? || flag_samesite_strict?
89
+ end
90
+
91
+ def flag_samesite_lax?
92
+ flag_samesite_enforcement?(:lax)
93
+ end
94
+
95
+ def flag_samesite_strict?
96
+ flag_samesite_enforcement?(:strict)
97
+ end
98
+
99
+ def flag_samesite_enforcement?(mode)
100
+ return unless config[:samesite]
101
+
102
+ case config[:samesite][mode]
103
+ when Hash
104
+ conditionally_flag?(config[:samesite][mode])
105
+ when TrueClass
106
+ true
107
+ else
108
+ false
109
+ end
110
+ end
111
+
112
+ def parse(cookie)
113
+ return unless cookie
114
+
115
+ cookie.split(/[;,]\s?/).each do |pairs|
116
+ name, values = pairs.split('=',2)
117
+ name = CGI.unescape(name)
118
+
119
+ attribute = name.downcase.to_sym
120
+ if @attributes.has_key?(attribute)
121
+ @attributes[attribute] = values || true
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,320 @@
1
+ module SecureHeaders
2
+ module PolicyManagement
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ MODERN_BROWSERS = %w(Chrome Opera Firefox)
8
+ DEFAULT_VALUE = "default-src https:".freeze
9
+ DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
10
+ HEADER_NAME = "Content-Security-Policy".freeze
11
+ REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze
12
+ HEADER_NAMES = [HEADER_NAME, REPORT_ONLY]
13
+ DATA_PROTOCOL = "data:".freeze
14
+ BLOB_PROTOCOL = "blob:".freeze
15
+ SELF = "'self'".freeze
16
+ NONE = "'none'".freeze
17
+ STAR = "*".freeze
18
+ UNSAFE_INLINE = "'unsafe-inline'".freeze
19
+ UNSAFE_EVAL = "'unsafe-eval'".freeze
20
+
21
+ # leftover deprecated values that will be in common use upon upgrading.
22
+ DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
23
+
24
+ DEFAULT_SRC = :default_src
25
+ CONNECT_SRC = :connect_src
26
+ FONT_SRC = :font_src
27
+ FRAME_SRC = :frame_src
28
+ IMG_SRC = :img_src
29
+ MEDIA_SRC = :media_src
30
+ OBJECT_SRC = :object_src
31
+ SANDBOX = :sandbox
32
+ SCRIPT_SRC = :script_src
33
+ STYLE_SRC = :style_src
34
+ REPORT_URI = :report_uri
35
+
36
+ DIRECTIVES_1_0 = [
37
+ DEFAULT_SRC,
38
+ CONNECT_SRC,
39
+ FONT_SRC,
40
+ FRAME_SRC,
41
+ IMG_SRC,
42
+ MEDIA_SRC,
43
+ OBJECT_SRC,
44
+ SANDBOX,
45
+ SCRIPT_SRC,
46
+ STYLE_SRC,
47
+ REPORT_URI
48
+ ].freeze
49
+
50
+ BASE_URI = :base_uri
51
+ CHILD_SRC = :child_src
52
+ FORM_ACTION = :form_action
53
+ FRAME_ANCESTORS = :frame_ancestors
54
+ PLUGIN_TYPES = :plugin_types
55
+
56
+ # These are directives that do not inherit the default-src value. This is
57
+ # useful when calling #combine_policies.
58
+ NON_FETCH_SOURCES = [
59
+ BASE_URI,
60
+ FORM_ACTION,
61
+ FRAME_ANCESTORS,
62
+ PLUGIN_TYPES,
63
+ REPORT_URI
64
+ ]
65
+
66
+ DIRECTIVES_2_0 = [
67
+ DIRECTIVES_1_0,
68
+ BASE_URI,
69
+ CHILD_SRC,
70
+ FORM_ACTION,
71
+ FRAME_ANCESTORS,
72
+ PLUGIN_TYPES
73
+ ].flatten.freeze
74
+
75
+ # All the directives currently under consideration for CSP level 3.
76
+ # https://w3c.github.io/webappsec/specs/CSP2/
77
+ MANIFEST_SRC = :manifest_src
78
+ REFLECTED_XSS = :reflected_xss
79
+ DIRECTIVES_3_0 = [
80
+ DIRECTIVES_2_0,
81
+ MANIFEST_SRC,
82
+ REFLECTED_XSS
83
+ ].flatten.freeze
84
+
85
+ # All the directives that are not currently in a formal spec, but have
86
+ # been implemented somewhere.
87
+ BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
88
+ UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests
89
+ DIRECTIVES_DRAFT = [
90
+ BLOCK_ALL_MIXED_CONTENT,
91
+ UPGRADE_INSECURE_REQUESTS
92
+ ].freeze
93
+
94
+ SAFARI_DIRECTIVES = DIRECTIVES_1_0
95
+
96
+ FIREFOX_UNSUPPORTED_DIRECTIVES = [
97
+ BLOCK_ALL_MIXED_CONTENT,
98
+ CHILD_SRC,
99
+ PLUGIN_TYPES
100
+ ].freeze
101
+
102
+ FIREFOX_DIRECTIVES = (
103
+ DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_UNSUPPORTED_DIRECTIVES
104
+ ).freeze
105
+
106
+ CHROME_DIRECTIVES = (
107
+ DIRECTIVES_2_0 + DIRECTIVES_DRAFT
108
+ ).freeze
109
+
110
+ ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
111
+
112
+ # Think of default-src and report-uri as the beginning and end respectively,
113
+ # everything else is in between.
114
+ BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
115
+
116
+ VARIATIONS = {
117
+ "Chrome" => CHROME_DIRECTIVES,
118
+ "Opera" => CHROME_DIRECTIVES,
119
+ "Firefox" => FIREFOX_DIRECTIVES,
120
+ "Safari" => SAFARI_DIRECTIVES,
121
+ "Other" => CHROME_DIRECTIVES
122
+ }.freeze
123
+
124
+ OTHER = "Other".freeze
125
+
126
+ DIRECTIVE_VALUE_TYPES = {
127
+ BASE_URI => :source_list,
128
+ BLOCK_ALL_MIXED_CONTENT => :boolean,
129
+ CHILD_SRC => :source_list,
130
+ CONNECT_SRC => :source_list,
131
+ DEFAULT_SRC => :source_list,
132
+ FONT_SRC => :source_list,
133
+ FORM_ACTION => :source_list,
134
+ FRAME_ANCESTORS => :source_list,
135
+ FRAME_SRC => :source_list,
136
+ IMG_SRC => :source_list,
137
+ MANIFEST_SRC => :source_list,
138
+ MEDIA_SRC => :source_list,
139
+ OBJECT_SRC => :source_list,
140
+ PLUGIN_TYPES => :source_list,
141
+ REFLECTED_XSS => :string,
142
+ REPORT_URI => :source_list,
143
+ SANDBOX => :string,
144
+ SCRIPT_SRC => :source_list,
145
+ STYLE_SRC => :source_list,
146
+ UPGRADE_INSECURE_REQUESTS => :boolean
147
+ }.freeze
148
+
149
+ CONFIG_KEY = :csp
150
+ STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
151
+ HTTP_SCHEME_REGEX = %r{\Ahttps?://}
152
+
153
+ WILDCARD_SOURCES = [
154
+ UNSAFE_EVAL,
155
+ UNSAFE_INLINE,
156
+ STAR,
157
+ DATA_PROTOCOL,
158
+ BLOB_PROTOCOL
159
+ ].freeze
160
+
161
+ META_CONFIGS = [
162
+ :report_only,
163
+ :preserve_schemes
164
+ ].freeze
165
+
166
+ module ClassMethods
167
+ # Public: generate a header name, value array that is user-agent-aware.
168
+ #
169
+ # Returns a default policy if no configuration is provided, or a
170
+ # header name and value based on the config.
171
+ def make_header(config, user_agent)
172
+ header = new(config, user_agent)
173
+ [header.name, header.value]
174
+ end
175
+
176
+ # Public: Validates each source expression.
177
+ #
178
+ # Does not validate the invididual values of the source expression (e.g.
179
+ # script_src => h*t*t*p: will not raise an exception)
180
+ def validate_config!(config)
181
+ return if config.nil? || config == OPT_OUT
182
+ raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
183
+ config.each do |key, value|
184
+ if META_CONFIGS.include?(key)
185
+ raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
186
+ else
187
+ validate_directive!(key, value)
188
+ end
189
+ end
190
+ end
191
+
192
+ # Public: determine if merging +additions+ will cause a change to the
193
+ # actual value of the config.
194
+ #
195
+ # e.g. config = { script_src: %w(example.org google.com)} and
196
+ # additions = { script_src: %w(google.com)} then idempotent_additions? would return
197
+ # because google.com is already in the config.
198
+ def idempotent_additions?(config, additions)
199
+ return true if config == OPT_OUT && additions == OPT_OUT
200
+ return false if config == OPT_OUT
201
+ config == combine_policies(config, additions)
202
+ end
203
+
204
+ # Public: combine the values from two different configs.
205
+ #
206
+ # original - the main config
207
+ # additions - values to be merged in
208
+ #
209
+ # raises an error if the original config is OPT_OUT
210
+ #
211
+ # 1. for non-source-list values (report_only, block_all_mixed_content, upgrade_insecure_requests),
212
+ # additions will overwrite the original value.
213
+ # 2. if a value in additions does not exist in the original config, the
214
+ # default-src value is included to match original behavior.
215
+ # 3. if a value in additions does exist in the original config, the two
216
+ # values are joined.
217
+ def combine_policies(original, additions)
218
+ if original == OPT_OUT
219
+ raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
220
+ end
221
+
222
+ original = Configuration.send(:deep_copy, original)
223
+ populate_fetch_source_with_default!(original, additions)
224
+ merge_policy_additions(original, additions)
225
+ end
226
+
227
+ def ua_to_variation(user_agent)
228
+ family = user_agent.browser
229
+ if family && VARIATIONS.key?(family)
230
+ family
231
+ else
232
+ OTHER
233
+ end
234
+ end
235
+
236
+ private
237
+
238
+ # merge the two hashes. combine (instead of overwrite) the array values
239
+ # when each hash contains a value for a given key.
240
+ def merge_policy_additions(original, additions)
241
+ original.merge(additions) do |directive, lhs, rhs|
242
+ if source_list?(directive)
243
+ (lhs.to_a + rhs.to_a).compact.uniq
244
+ else
245
+ rhs
246
+ end
247
+ end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
248
+ end
249
+
250
+ # For each directive in additions that does not exist in the original config,
251
+ # copy the default-src value to the original config. This modifies the original hash.
252
+ def populate_fetch_source_with_default!(original, additions)
253
+ # in case we would be appending to an empty directive, fill it with the default-src value
254
+ additions.keys.each do |directive|
255
+ unless original[directive] || !source_list?(directive) || NON_FETCH_SOURCES.include?(directive)
256
+ original[directive] = original[:default_src]
257
+ end
258
+ end
259
+ end
260
+
261
+ def source_list?(directive)
262
+ DIRECTIVE_VALUE_TYPES[directive] == :source_list
263
+ end
264
+
265
+ # Private: Validates that the configuration has a valid type, or that it is a valid
266
+ # source expression.
267
+ def validate_directive!(directive, source_expression)
268
+ case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive]
269
+ when :boolean
270
+ unless boolean?(source_expression)
271
+ raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean value")
272
+ end
273
+ when :string
274
+ unless source_expression.is_a?(String)
275
+ raise ContentSecurityPolicyConfigError.new("#{directive} Must be a string. Found #{config.class}: #{config} value")
276
+ end
277
+ else
278
+ validate_source_expression!(directive, source_expression)
279
+ end
280
+ end
281
+
282
+ # Private: validates that a source expression:
283
+ # 1. has a valid name
284
+ # 2. is an array of strings
285
+ # 3. does not contain any depreated, now invalid values (inline, eval, self, none)
286
+ #
287
+ # Does not validate the invididual values of the source expression (e.g.
288
+ # script_src => h*t*t*p: will not raise an exception)
289
+ def validate_source_expression!(directive, source_expression)
290
+ ensure_valid_directive!(directive)
291
+ ensure_array_of_strings!(directive, source_expression)
292
+ ensure_valid_sources!(directive, source_expression)
293
+ end
294
+
295
+ def ensure_valid_directive!(directive)
296
+ unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(directive)
297
+ raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}")
298
+ end
299
+ end
300
+
301
+ def ensure_array_of_strings!(directive, source_expression)
302
+ unless source_expression.is_a?(Array) && source_expression.compact.all? { |v| v.is_a?(String) }
303
+ raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings")
304
+ end
305
+ end
306
+
307
+ def ensure_valid_sources!(directive, source_expression)
308
+ source_expression.each do |source_expression|
309
+ if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(source_expression)
310
+ raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{source_expression}). This value must be single quoted.")
311
+ end
312
+ end
313
+ end
314
+
315
+ def boolean?(source_expression)
316
+ source_expression.is_a?(TrueClass) || source_expression.is_a?(FalseClass)
317
+ end
318
+ end
319
+ end
320
+ end
@@ -1,6 +1,6 @@
1
1
  module SecureHeaders
2
2
  class XContentTypeOptionsConfigError < StandardError; end
3
- # IE only
3
+
4
4
  class XContentTypeOptions
5
5
  HEADER_NAME = "X-Content-Type-Options"
6
6
  DEFAULT_VALUE = "nosniff"
@@ -8,8 +8,45 @@ module SecureHeaders
8
8
  def call(env)
9
9
  req = Rack::Request.new(env)
10
10
  status, headers, response = @app.call(env)
11
+
12
+ config = SecureHeaders.config_for(req)
13
+ flag_cookies!(headers, override_secure(env, config.cookies)) if config.cookies
11
14
  headers.merge!(SecureHeaders.header_hash_for(req))
12
15
  [status, headers, response]
13
16
  end
17
+
18
+ private
19
+
20
+ # inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
21
+ def flag_cookies!(headers, config)
22
+ if cookies = headers['Set-Cookie']
23
+ # Support Rails 2.3 / Rack 1.1 arrays as headers
24
+ cookies = cookies.split("\n") unless cookies.is_a?(Array)
25
+
26
+ headers['Set-Cookie'] = cookies.map do |cookie|
27
+ SecureHeaders::Cookie.new(cookie, config).to_s
28
+ end.join("\n")
29
+ end
30
+ end
31
+
32
+ # disable Secure cookies for non-https requests
33
+ def override_secure(env, config = {})
34
+ if scheme(env) != 'https'
35
+ config.merge!(secure: false)
36
+ end
37
+
38
+ config
39
+ end
40
+
41
+ # derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
42
+ def scheme(env)
43
+ if env['HTTPS'] == 'on' || env['HTTP_X_SSL_REQUEST'] == 'on'
44
+ 'https'
45
+ elsif env['HTTP_X_FORWARDED_PROTO']
46
+ env['HTTP_X_FORWARDED_PROTO'].split(',')[0]
47
+ else
48
+ env['rack.url_scheme']
49
+ end
50
+ end
14
51
  end
15
52
  end
@@ -10,7 +10,11 @@ if defined?(Rails::Railtie)
10
10
  'Public-Key-Pins', 'Public-Key-Pins-Report-Only']
11
11
 
12
12
  initializer "secure_headers.middleware" do
13
- Rails.application.config.middleware.use SecureHeaders::Middleware
13
+ Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
14
+ end
15
+
16
+ rake_tasks do
17
+ load File.expand_path(File.join('..', '..', 'lib', 'tasks', 'tasks.rake'), File.dirname(__FILE__))
14
18
  end
15
19
 
16
20
  initializer "secure_headers.action_controller" do
@@ -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