secure_headers 1.4.0 → 2.0.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.
Files changed (66) hide show
  1. data/.gitignore +4 -8
  2. data/.travis.yml +7 -1
  3. data/Gemfile +4 -4
  4. data/README.md +115 -79
  5. data/Rakefile +11 -116
  6. data/fixtures/rails_3_2_12/app/views/layouts/application.html.erb +1 -1
  7. data/fixtures/rails_3_2_12/app/views/other_things/index.html.erb +2 -1
  8. data/fixtures/rails_3_2_12/config/initializers/secure_headers.rb +2 -1
  9. data/fixtures/rails_3_2_12/config/script_hashes.yml +5 -0
  10. data/fixtures/rails_3_2_12/config.ru +3 -0
  11. data/fixtures/rails_3_2_12/spec/controllers/other_things_controller_spec.rb +55 -18
  12. data/fixtures/rails_3_2_12/spec/controllers/things_controller_spec.rb +6 -1
  13. data/fixtures/rails_3_2_12_no_init/app/controllers/other_things_controller.rb +1 -2
  14. data/fixtures/rails_3_2_12_no_init/spec/controllers/other_things_controller_spec.rb +5 -0
  15. data/fixtures/rails_3_2_12_no_init/spec/controllers/things_controller_spec.rb +5 -0
  16. data/fixtures/rails_4_1_8/Gemfile +5 -0
  17. data/fixtures/rails_4_1_8/README.rdoc +28 -0
  18. data/fixtures/rails_4_1_8/Rakefile +6 -0
  19. data/fixtures/rails_4_1_8/app/controllers/application_controller.rb +4 -0
  20. data/fixtures/rails_4_1_8/app/controllers/concerns/.keep +0 -0
  21. data/fixtures/rails_4_1_8/app/controllers/other_things_controller.rb +5 -0
  22. data/fixtures/rails_4_1_8/app/controllers/things_controller.rb +5 -0
  23. data/fixtures/rails_4_1_8/app/models/.keep +0 -0
  24. data/fixtures/rails_4_1_8/app/models/concerns/.keep +0 -0
  25. data/fixtures/rails_4_1_8/app/views/layouts/application.html.erb +11 -0
  26. data/fixtures/rails_4_1_8/app/views/other_things/index.html.erb +2 -0
  27. data/fixtures/rails_4_1_8/app/views/things/index.html.erb +1 -0
  28. data/fixtures/rails_4_1_8/config/application.rb +15 -0
  29. data/fixtures/rails_4_1_8/config/boot.rb +4 -0
  30. data/fixtures/rails_4_1_8/config/environment.rb +5 -0
  31. data/fixtures/rails_4_1_8/config/environments/test.rb +10 -0
  32. data/fixtures/rails_4_1_8/config/initializers/secure_headers.rb +17 -0
  33. data/fixtures/rails_4_1_8/config/routes.rb +4 -0
  34. data/fixtures/rails_4_1_8/config/script_hashes.yml +5 -0
  35. data/fixtures/rails_4_1_8/config/secrets.yml +22 -0
  36. data/fixtures/rails_4_1_8/config.ru +4 -0
  37. data/fixtures/rails_4_1_8/lib/assets/.keep +0 -0
  38. data/fixtures/rails_4_1_8/lib/tasks/.keep +0 -0
  39. data/fixtures/rails_4_1_8/log/.keep +0 -0
  40. data/fixtures/rails_4_1_8/spec/controllers/other_things_controller_spec.rb +83 -0
  41. data/fixtures/rails_4_1_8/spec/controllers/things_controller_spec.rb +59 -0
  42. data/fixtures/rails_4_1_8/spec/spec_helper.rb +15 -0
  43. data/fixtures/rails_4_1_8/vendor/assets/javascripts/.keep +0 -0
  44. data/fixtures/rails_4_1_8/vendor/assets/stylesheets/.keep +0 -0
  45. data/lib/secure_headers/hash_helper.rb +7 -0
  46. data/lib/secure_headers/headers/content_security_policy/script_hash_middleware.rb +22 -0
  47. data/lib/secure_headers/headers/content_security_policy.rb +141 -135
  48. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +40 -0
  49. data/lib/secure_headers/railtie.rb +13 -22
  50. data/lib/secure_headers/version.rb +1 -1
  51. data/lib/secure_headers/view_helper.rb +68 -0
  52. data/lib/secure_headers.rb +59 -17
  53. data/lib/tasks/tasks.rake +48 -0
  54. data/secure_headers.gemspec +4 -4
  55. data/spec/lib/secure_headers/headers/content_security_policy/script_hash_middleware_spec.rb +47 -0
  56. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +83 -208
  57. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +1 -1
  58. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +64 -0
  59. data/spec/lib/secure_headers_spec.rb +36 -61
  60. data/spec/spec_helper.rb +26 -1
  61. metadata +44 -11
  62. data/HISTORY.md +0 -162
  63. data/app/controllers/content_security_policy_controller.rb +0 -76
  64. data/config/curl-ca-bundle.crt +0 -5420
  65. data/config/routes.rb +0 -3
  66. data/spec/controllers/content_security_policy_controller_spec.rb +0 -90
@@ -1,91 +1,154 @@
1
1
  require 'uri'
2
2
  require 'base64'
3
+ require 'securerandom'
3
4
 
4
5
  module SecureHeaders
5
6
  class ContentSecurityPolicyBuildError < StandardError; end
6
7
  class ContentSecurityPolicy < Header
7
8
  module Constants
8
- DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https://* about: javascript:; img-src data:"
9
- STANDARD_HEADER_NAME = "Content-Security-Policy"
10
- FF_CSP_ENDPOINT = "/content_security_policy/forward_report"
11
- DIRECTIVES = [:default_src, :script_src, :frame_src, :style_src, :img_src, :media_src, :font_src, :object_src, :connect_src]
12
- META = [:disable_chrome_extension, :disable_fill_missing, :forward_endpoint]
9
+ DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https: about: javascript:; img-src data:"
10
+ HEADER_NAME = "Content-Security-Policy"
11
+ ENV_KEY = 'secure_headers.content_security_policy'
12
+ DIRECTIVES = [
13
+ :default_src,
14
+ :connect_src,
15
+ :font_src,
16
+ :frame_src,
17
+ :img_src,
18
+ :media_src,
19
+ :object_src,
20
+ :script_src,
21
+ :style_src
22
+ ]
23
+
24
+ NON_DEFAULT_SOURCES = [
25
+ :base_uri,
26
+ :child_src,
27
+ :form_action,
28
+ :frame_ancestors,
29
+ :plugin_types,
30
+ :referrer,
31
+ :reflected_xss
32
+ ]
33
+
34
+ ALL_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES
13
35
  end
14
36
  include Constants
15
37
 
16
- attr_accessor *META
17
- attr_reader :browser, :ssl_request, :report_uri, :request_uri, :experimental
18
-
19
- alias :disable_chrome_extension? :disable_chrome_extension
38
+ attr_reader :disable_fill_missing, :ssl_request
20
39
  alias :disable_fill_missing? :disable_fill_missing
21
40
  alias :ssl_request? :ssl_request
22
41
 
42
+ class << self
43
+ def generate_nonce
44
+ SecureRandom.base64(32).chomp
45
+ end
46
+
47
+ def set_nonce(controller, nonce = generate_nonce)
48
+ controller.instance_variable_set(:@content_security_policy_nonce, nonce)
49
+ end
50
+
51
+ def add_to_env(request, controller, config)
52
+ set_nonce(controller)
53
+ options = options_from_request(request).merge(:controller => controller)
54
+ request.env[Constants::ENV_KEY] = {
55
+ :config => config,
56
+ :options => options,
57
+ }
58
+ end
59
+
60
+ def options_from_request(request)
61
+ {
62
+ :ssl => request.ssl?,
63
+ :ua => request.env['HTTP_USER_AGENT'],
64
+ :request_uri => request_uri_from_request(request),
65
+ }
66
+ end
67
+
68
+ def request_uri_from_request(request)
69
+ if request.respond_to?(:original_url)
70
+ # rails 3.1+
71
+ request.original_url
72
+ else
73
+ # rails 2/3.0
74
+ request.url
75
+ end
76
+ end
77
+
78
+ def symbol_to_hyphen_case sym
79
+ sym.to_s.gsub('_', '-')
80
+ end
81
+ end
82
+
23
83
  # +options+ param contains
24
- # :experimental use experimental block for config
84
+ # :controller used for setting instance variables for nonces/hashes
25
85
  # :ssl_request used to determine if http_additions should be used
26
- # :request_uri used to determine if firefox should send the report directly
27
- # or use the forwarding endpoint
28
86
  # :ua the user agent (or just use Firefox/Chrome/MSIE/etc)
29
87
  #
30
88
  # :report used to determine what :ssl_request, :ua, and :request_uri are set to
31
89
  def initialize(config=nil, options={})
32
- @experimental = !!options.delete(:experimental)
33
- warn "[DEPRECATION] 'experimental' config is removed in 2.0"
34
- @controller = options.delete(:controller)
90
+ return unless config
35
91
 
36
92
  if options[:request]
37
- parse_request(options[:request])
38
- else
39
- @ua = options[:ua]
40
- # fails open, assumes http. Bad idea? Will always include http additions.
41
- # could also fail if not supplied.
42
- @ssl_request = !!options.delete(:ssl)
43
- # a nil value here means we always assume we are not on the same host,
44
- # which causes all FF csp reports to go through the forwarder
45
- @request_uri = options.delete(:request_uri)
93
+ options = options.merge(self.class.options_from_request(options[:request]))
46
94
  end
47
95
 
48
- configure(config) if config
49
- end
96
+ @controller = options[:controller]
97
+ @ua = options[:ua]
98
+ @ssl_request = !!options.delete(:ssl)
99
+ @request_uri = options.delete(:request_uri)
50
100
 
51
- def nonce
52
- @nonce ||= SecureRandom.base64(32).chomp
53
- end
101
+ # Config values can be string, array, or lamdba values
102
+ @config = config.inject({}) do |hash, (key, value)|
103
+ config_val = value.respond_to?(:call) ? value.call : value
54
104
 
55
- def configure(config)
56
- @config = config.dup
105
+ if ALL_DIRECTIVES.include?(key) # directives need to be normalized to arrays of strings
106
+ config_val = config_val.split if config_val.is_a? String
107
+ if config_val.is_a?(Array)
108
+ config_val = config_val.map do |val|
109
+ translate_dir_value(val)
110
+ end.flatten.uniq
111
+ end
112
+ end
57
113
 
58
- experimental_config = @config.delete(:experimental)
59
- if @experimental && experimental_config
60
- @config[:http_additions] = experimental_config[:http_additions]
61
- @config.merge!(experimental_config)
114
+ hash[key] = config_val
115
+ hash
62
116
  end
63
117
 
64
- # these values don't support lambdas because this needs to be rewritten
65
118
  @http_additions = @config.delete(:http_additions)
66
119
  @app_name = @config.delete(:app_name)
120
+ @report_uri = @config.delete(:report_uri)
67
121
 
68
- normalize_csp_options
69
-
70
- META.each do |meta|
71
- self.send("#{meta}=", @config.delete(meta))
72
- end
73
-
122
+ @disable_fill_missing = !!@config.delete(:disable_fill_missing)
74
123
  @enforce = !!@config.delete(:enforce)
75
- @tag_report_uri = @config.delete(:tag_report_uri)
124
+ @tag_report_uri = !!@config.delete(:tag_report_uri)
125
+ @script_hashes = @config.delete(:script_hashes) || []
76
126
 
77
- normalize_reporting_endpoint
127
+ add_script_hashes if @script_hashes.any?
78
128
  fill_directives unless disable_fill_missing?
79
129
  end
80
130
 
131
+ ##
132
+ # Return or initialize the nonce value used for this header.
133
+ # If a reference to a controller is passed in the config, this method
134
+ # will check if a nonce has already been set and use it.
135
+ def nonce
136
+ @nonce ||= @controller.instance_variable_get(:@content_security_policy_nonce) || self.class.generate_nonce
137
+ end
138
+
139
+ ##
140
+ # Returns the name to use for the header. Either "Content-Security-Policy" or
141
+ # "Content-Security-Policy-Report-Only"
81
142
  def name
82
- base = STANDARD_HEADER_NAME
83
- if !@enforce || experimental
143
+ base = HEADER_NAME
144
+ if !@enforce
84
145
  base += "-Report-Only"
85
146
  end
86
147
  base
87
148
  end
88
149
 
150
+ ##
151
+ # Return the value of the CSP header
89
152
  def value
90
153
  return @config if @config.is_a?(String)
91
154
  if @config
@@ -97,26 +160,28 @@ module SecureHeaders
97
160
 
98
161
  private
99
162
 
163
+ def add_script_hashes
164
+ @config[:script_src] << @script_hashes.map {|hash| "'#{hash}'"} << ["'unsafe-inline'"]
165
+ end
166
+
100
167
  def build_value
101
168
  raise "Expected to find default_src directive value" unless @config[:default_src]
102
169
  append_http_additions unless ssl_request?
103
170
  header_value = [
104
- generic_directives(@config),
171
+ generic_directives,
172
+ non_default_directives,
105
173
  report_uri_directive
106
174
  ].join.strip
107
- rescue StandardError => e
108
- raise ContentSecurityPolicyBuildError.new("Couldn't build CSP header :( #{e}")
109
175
  end
110
176
 
111
177
  def fill_directives
112
- return unless @config[:default_src]
113
- default = @config[:default_src]
114
- DIRECTIVES.each do |directive|
115
- unless @config[directive]
116
- @config[directive] = default
178
+ if default = @config[:default_src]
179
+ DIRECTIVES.each do |directive|
180
+ unless @config[directive]
181
+ @config[directive] = default
182
+ end
117
183
  end
118
184
  end
119
- @config
120
185
  end
121
186
 
122
187
  def append_http_additions
@@ -127,75 +192,20 @@ module SecureHeaders
127
192
  end
128
193
  end
129
194
 
130
- def normalize_csp_options
131
- @config = @config.inject({}) do |hash, (key, value)|
132
- # lambdas
133
- config_val = value.respond_to?(:call) ? value.call : value
134
- # space-delimeted strings
135
- config_val = config_val.split if config_val.is_a? String
136
- # array of strings
137
- if config_val.respond_to?(:map) #skip booleans
138
- config_val = config_val.map do |val|
139
- translate_dir_value(val)
140
- end.flatten.uniq
141
- end
142
-
143
- hash[key] = config_val
144
- hash
145
- end
146
-
147
- @report_uri = @config.delete(:report_uri).join(" ") if @config[:report_uri]
148
- end
149
-
150
- # translates 'inline','self', 'none' and 'eval' to their respective impl-specific values.
151
195
  def translate_dir_value val
152
196
  if %w{inline eval}.include?(val)
153
197
  val == 'inline' ? "'unsafe-inline'" : "'unsafe-eval'"
154
- # self/none are special sources/src-dir-values and need to be quoted in chrome
198
+ # self/none are special sources/src-dir-values and need to be quoted
155
199
  elsif %{self none}.include?(val)
156
200
  "'#{val}'"
157
201
  elsif val == 'nonce'
158
- @controller.instance_variable_set(:@content_security_policy_nonce, nonce)
202
+ self.class.set_nonce(@controller, nonce)
159
203
  ["'nonce-#{nonce}'", "'unsafe-inline'"]
160
204
  else
161
205
  val
162
206
  end
163
207
  end
164
208
 
165
- # if we have a forwarding endpoint setup and we are not on the same origin as our report_uri
166
- # or only a path was supplied (in which case we assume cross-host)
167
- # we need to forward the request for Firefox.
168
- def normalize_reporting_endpoint
169
- if @ua && @ua =~ /Firefox/
170
- if same_origin? || report_uri.nil? || URI.parse(report_uri).host.nil?
171
- return
172
- end
173
-
174
- if forward_endpoint
175
- warn "[DEPRECATION] forwarder is removed in 2.0"
176
- @report_uri = FF_CSP_ENDPOINT
177
- end
178
- end
179
-
180
- if @tag_report_uri
181
- @report_uri = "#{@report_uri}?enforce=#{@enforce}"
182
- @report_uri += "&app_name=#{@app_name}" if @app_name
183
- end
184
- end
185
-
186
- def same_origin?
187
- return unless report_uri && request_uri
188
-
189
- begin
190
- origin = URI.parse(request_uri)
191
- uri = URI.parse(report_uri)
192
- rescue URI::InvalidURIError
193
- return false
194
- end
195
-
196
- uri.host == origin.host && origin.port == uri.port && origin.scheme == uri.scheme
197
- end
198
-
199
209
  def report_uri_directive
200
210
  return '' if @report_uri.nil?
201
211
 
@@ -207,44 +217,40 @@ module SecureHeaders
207
217
  end
208
218
  end
209
219
 
220
+ if @tag_report_uri
221
+ @report_uri = "#{@report_uri}?enforce=#{@enforce}"
222
+ @report_uri += "&app_name=#{@app_name}" if @app_name
223
+ end
224
+
210
225
  "report-uri #{@report_uri};"
211
226
  end
212
227
 
213
- def generic_directives(config)
228
+ def generic_directives
214
229
  header_value = ''
215
- if config[:img_src]
216
- config[:img_src] = config[:img_src] + ['data:'] unless config[:img_src].include?('data:')
230
+ if @config[:img_src]
231
+ @config[:img_src] = @config[:img_src] + ['data:'] unless @config[:img_src].include?('data:')
217
232
  else
218
- config[:img_src] = config[:default_src] + ['data:']
233
+ @config[:img_src] = @config[:default_src] + ['data:']
219
234
  end
220
235
 
221
- header_value = build_directive(:default_src)
222
- config.keys.sort_by{|k| k.to_s}.each do |k| # ensure consistent ordering
223
- header_value += build_directive(k)
236
+ DIRECTIVES.each do |directive_name|
237
+ header_value += build_directive(directive_name) if @config[directive_name]
224
238
  end
225
239
 
226
240
  header_value
227
241
  end
228
242
 
229
- # build and deletes the directive
230
- def build_directive(key)
231
- "#{symbol_to_hyphen_case(key)} #{@config.delete(key).join(" ")}; "
232
- end
243
+ def non_default_directives
244
+ header_value = ''
245
+ NON_DEFAULT_SOURCES.each do |directive_name|
246
+ header_value += build_directive(directive_name) if @config[directive_name]
247
+ end
233
248
 
234
- def symbol_to_hyphen_case sym
235
- sym.to_s.gsub('_', '-')
249
+ header_value
236
250
  end
237
251
 
238
- def parse_request request
239
- @ssl_request = request.ssl?
240
- @ua = request.env['HTTP_USER_AGENT']
241
- @request_uri = if request.respond_to?(:original_url)
242
- # rails 3.1+
243
- request.original_url
244
- else
245
- # rails 2/3.0
246
- request.url
247
- end
252
+ def build_directive(key)
253
+ "#{self.class.symbol_to_hyphen_case(key)} #{@config[key].join(" ")}; "
248
254
  end
249
255
  end
250
256
  end
@@ -0,0 +1,40 @@
1
+ module SecureHeaders
2
+ class XPCDPBuildError < StandardError; end
3
+ class XPermittedCrossDomainPolicies < Header
4
+ module Constants
5
+ XPCDP_HEADER_NAME = "X-Permitted-Cross-Domain-Policies"
6
+ DEFAULT_VALUE = 'none'
7
+ VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename)
8
+ end
9
+ include Constants
10
+
11
+ def initialize(config = nil)
12
+ @config = config
13
+ validate_config unless @config.nil?
14
+ end
15
+
16
+ def name
17
+ XPCDP_HEADER_NAME
18
+ end
19
+
20
+ def value
21
+ case @config
22
+ when NilClass
23
+ DEFAULT_VALUE
24
+ when String
25
+ @config
26
+ else
27
+ @config[:value]
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def validate_config
34
+ value = @config.is_a?(Hash) ? @config[:value] : @config
35
+ unless VALID_POLICIES.include?(value.downcase)
36
+ raise XPCDPBuildError.new("Value can only be one of #{VALID_POLICIES.join(', ')}")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,9 +3,22 @@ if defined?(Rails::Railtie)
3
3
  module SecureHeaders
4
4
  class Railtie < Rails::Engine
5
5
  isolate_namespace ::SecureHeaders if defined? isolate_namespace # rails 3.0
6
+ conflicting_headers = ['X-Frame-Options', 'X-XSS-Protection', 'X-Content-Type-Options',
7
+ 'X-Permitted-Cross-Domain-Policies', 'X-Download-Options',
8
+ 'X-Content-Type-Options', 'Strict-Transport-Security',
9
+ 'Content-Security-Policy', 'Content-Security-Policy-Report-Only',
10
+ 'X-Permitted-Cross-Domain-Policies','Public-Key-Pins','Public-Key-Pins-Report-Only']
11
+
6
12
  initializer "secure_headers.action_controller" do
7
13
  ActiveSupport.on_load(:action_controller) do
8
14
  include ::SecureHeaders
15
+
16
+ unless Rails.application.config.action_dispatch.default_headers.nil?
17
+ conflicting_headers.each do |header|
18
+ Rails.application.config.action_dispatch.default_headers.delete(header)
19
+ end
20
+ end
21
+
9
22
  end
10
23
  end
11
24
  end
@@ -16,26 +29,4 @@ else
16
29
  include ::SecureHeaders
17
30
  end
18
31
  end
19
-
20
- module SecureHeaders
21
- module Routing
22
- module MapperExtensions
23
- def csp_endpoint
24
- @set.add_route(ContentSecurityPolicy::FF_CSP_ENDPOINT, {:controller => "content_security_policy", :action => "scribe"})
25
- end
26
- end
27
- end
28
- end
29
-
30
- if defined?(ActiveSupport::Dependencies)
31
- if ActiveSupport::Dependencies.autoload_paths
32
- ActiveSupport::Dependencies.autoload_paths << File.expand_path(File.join("..", "..", "..", "app", "controllers"), __FILE__)
33
- else
34
- ActiveSupport::Dependencies.autoload_paths = [File.expand_path(File.join("..", "..", "..", "app", "controllers"), __FILE__)]
35
- end
36
- end
37
-
38
- if defined? ActionController::Routing
39
- ActionController::Routing::RouteSet::Mapper.send :include, ::SecureHeaders::Routing::MapperExtensions
40
- end
41
32
  end
@@ -1,3 +1,3 @@
1
1
  module SecureHeaders
2
- VERSION = "1.4.0"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -0,0 +1,68 @@
1
+ module SecureHeaders
2
+ class UnexpectedHashedScriptException < StandardError
3
+
4
+ end
5
+
6
+ module ViewHelpers
7
+ include SecureHeaders::HashHelper
8
+ SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
9
+
10
+ def nonced_style_tag(content = nil, &block)
11
+ nonced_tag(content, :style, block)
12
+ end
13
+
14
+ def nonced_javascript_tag(content = nil, &block)
15
+ nonced_tag(content, :script, block)
16
+ end
17
+
18
+ def hashed_javascript_tag(raise_error_on_unrecognized_hash = false, &block)
19
+ content = capture(&block)
20
+
21
+ if ['development', 'test'].include?(ENV["RAILS_ENV"])
22
+ hash_value = hash_source(content)
23
+ file_path = File.join('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')
24
+ script_hashes = controller.instance_variable_get(:@script_hashes)[file_path]
25
+ unless script_hashes && script_hashes.include?(hash_value)
26
+ message = unexpected_hash_error_message(file_path, hash_value, content)
27
+ if raise_error_on_unrecognized_hash
28
+ raise UnexpectedHashedScriptException.new(message)
29
+ else
30
+ puts message
31
+ request.env[HASHES_ENV_KEY] = (request.env[HASHES_ENV_KEY] || []) << hash_value
32
+ end
33
+ end
34
+ end
35
+
36
+ content_tag :script, content
37
+ end
38
+
39
+ private
40
+
41
+ def nonced_tag(content, type, block)
42
+ content = if block
43
+ capture(&block)
44
+ else
45
+ content.html_safe # :'(
46
+ end
47
+
48
+ content_tag type, content, :nonce => @content_security_policy_nonce
49
+ end
50
+
51
+ def unexpected_hash_error_message(file_path, hash_value, content)
52
+ <<-EOF
53
+ \n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
54
+ <script>#{content}</script>
55
+ *** This is fine in dev/test, but will raise exceptions in production. ***
56
+ *** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/script_hashes.yml:***
57
+ #{file_path}:
58
+ - #{hash_value}\n\n
59
+ EOF
60
+ end
61
+ end
62
+ end
63
+
64
+ module ActionView #:nodoc:
65
+ module Helpers #:nodoc:
66
+ include SecureHeaders::ViewHelpers
67
+ end
68
+ end
@@ -1,11 +1,18 @@
1
1
  module SecureHeaders
2
+ SCRIPT_HASH_CONFIG_FILE = 'config/script_hashes.yml'
3
+ HASHES_ENV_KEY = 'secure_headers.script_hashes'
4
+
2
5
  module Configuration
3
6
  class << self
4
7
  attr_accessor :hsts, :x_frame_options, :x_content_type_options,
5
- :x_xss_protection, :csp, :x_download_options
8
+ :x_xss_protection, :csp, :x_download_options, :script_hashes,
9
+ :x_permitted_cross_domain_policies
6
10
 
7
11
  def configure &block
8
12
  instance_eval &block
13
+ if File.exists?(SCRIPT_HASH_CONFIG_FILE)
14
+ ::SecureHeaders::Configuration.script_hashes = YAML.load(File.open(SCRIPT_HASH_CONFIG_FILE))
15
+ end
9
16
  end
10
17
  end
11
18
  end
@@ -33,12 +40,14 @@ module SecureHeaders
33
40
 
34
41
  def ensure_security_headers options = {}
35
42
  self.secure_headers_options = options
43
+ before_filter :prep_script_hash
36
44
  before_filter :set_hsts_header
37
45
  before_filter :set_x_frame_options_header
38
46
  before_filter :set_csp_header
39
47
  before_filter :set_x_xss_protection_header
40
48
  before_filter :set_x_content_type_options_header
41
49
  before_filter :set_x_download_options_header
50
+ before_filter :set_x_permitted_cross_domain_policies_header
42
51
  end
43
52
 
44
53
  # we can't use ||= because I'm overloading false => disable, nil => default
@@ -49,7 +58,6 @@ module SecureHeaders
49
58
  end
50
59
 
51
60
  module InstanceMethods
52
- # Re-added for backwards compat.
53
61
  def set_security_headers(options = self.class.secure_headers_options)
54
62
  set_csp_header(request, options[:csp])
55
63
  set_hsts_header(options[:hsts])
@@ -57,30 +65,57 @@ module SecureHeaders
57
65
  set_x_xss_protection_header(options[:x_xss_protection])
58
66
  set_x_content_type_options_header(options[:x_content_type_options])
59
67
  set_x_download_options_header(options[:x_download_options])
68
+ set_x_permitted_cross_domain_policies_header(options[:x_permitted_cross_domain_policies])
60
69
  end
61
70
 
62
- # backwards compatibility jank, to be removed in 1.0. Old API required a request
63
- # object when it didn't really need to.
64
71
  # set_csp_header - uses the request accessor and SecureHeader::Configuration settings
65
72
  # set_csp_header(+Rack::Request+) - uses the parameter and and SecureHeader::Configuration settings
66
73
  # set_csp_header(+Hash+) - uses the request accessor and options from parameters
67
74
  # set_csp_header(+Rack::Request+, +Hash+)
68
- def set_csp_header(req = nil, options=nil)
69
- # hack to help generating headers statically
70
- if req.is_a?(Hash)
71
- options = req
75
+ def set_csp_header(req = nil, config=nil)
76
+ if req.is_a?(Hash) || req.is_a?(FalseClass)
77
+ config = req
72
78
  end
73
79
 
74
- options = self.class.secure_headers_options[:csp] if options.nil?
75
- options = self.class.options_for :csp, options
80
+ config = self.class.secure_headers_options[:csp] if config.nil?
81
+ config = self.class.options_for :csp, config
76
82
 
77
- return if options == false
83
+ return if config == false
84
+
85
+ if config && config[:script_hash_middleware]
86
+ ContentSecurityPolicy.add_to_env(request, self, config)
87
+ else
88
+ csp_header = ContentSecurityPolicy.new(config, :request => request, :controller => self)
89
+ set_header(csp_header)
90
+ end
91
+ end
92
+
93
+
94
+ def prep_script_hash
95
+ if ::SecureHeaders::Configuration.script_hashes
96
+ @script_hashes = ::SecureHeaders::Configuration.script_hashes.dup
97
+ ActiveSupport::Notifications.subscribe("render_partial.action_view") do |event_name, start_at, end_at, id, payload|
98
+ save_hash_for_later payload
99
+ end
100
+
101
+ ActiveSupport::Notifications.subscribe("render_template.action_view") do |event_name, start_at, end_at, id, payload|
102
+ save_hash_for_later payload
103
+ end
104
+ end
105
+ end
106
+
107
+ def save_hash_for_later payload
108
+ matching_hashes = @script_hashes[payload[:identifier].gsub(Rails.root.to_s + "/", "")] || []
78
109
 
79
- csp_header = ContentSecurityPolicy.new(options, :request => request, :controller => self)
80
- set_header(csp_header)
81
- if options && options[:experimental] && options[:enforce]
82
- experimental_header = ContentSecurityPolicy.new(options, :experimental => true, :request => request, :controller => self)
83
- set_header(experimental_header)
110
+ if payload[:layout]
111
+ # We're assuming an html.erb layout for now. Will need to handle mustache too, just not sure of the best way to do this
112
+ layout_hashes = @script_hashes[File.join("app", "views", payload[:layout]) + '.html.erb']
113
+
114
+ matching_hashes << layout_hashes if layout_hashes
115
+ end
116
+
117
+ if matching_hashes.any?
118
+ request.env[HASHES_ENV_KEY] = ((request.env[HASHES_ENV_KEY] || []) << matching_hashes).flatten
84
119
  end
85
120
  end
86
121
 
@@ -105,6 +140,10 @@ module SecureHeaders
105
140
  set_a_header(:x_download_options, XDownloadOptions, options)
106
141
  end
107
142
 
143
+ def set_x_permitted_cross_domain_policies_header(options=self.class.secure_headers_options[:x_permitted_cross_domain_policies])
144
+ set_a_header(:x_permitted_cross_domain_policies, XPermittedCrossDomainPolicies, options)
145
+ end
146
+
108
147
  private
109
148
 
110
149
  def set_a_header(name, klass, options=nil)
@@ -126,7 +165,7 @@ module SecureHeaders
126
165
  end
127
166
  end
128
167
 
129
- require "securerandom"
168
+
130
169
  require "secure_headers/version"
131
170
  require "secure_headers/header"
132
171
  require "secure_headers/headers/content_security_policy"
@@ -135,4 +174,7 @@ require "secure_headers/headers/strict_transport_security"
135
174
  require "secure_headers/headers/x_xss_protection"
136
175
  require "secure_headers/headers/x_content_type_options"
137
176
  require "secure_headers/headers/x_download_options"
177
+ require "secure_headers/headers/x_permitted_cross_domain_policies"
138
178
  require "secure_headers/railtie"
179
+ require "secure_headers/hash_helper"
180
+ require "secure_headers/view_helper"