secure_headers 2.2.0 → 3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +3 -1
  5. data/CHANGELOG.md +205 -0
  6. data/Gemfile +9 -10
  7. data/Guardfile +12 -0
  8. data/README.md +175 -276
  9. data/Rakefile +2 -36
  10. data/lib/secure_headers/configuration.rb +189 -0
  11. data/lib/secure_headers/headers/content_security_policy.rb +367 -188
  12. data/lib/secure_headers/headers/public_key_pins.rb +43 -57
  13. data/lib/secure_headers/headers/strict_transport_security.rb +21 -47
  14. data/lib/secure_headers/headers/x_content_type_options.rb +19 -32
  15. data/lib/secure_headers/headers/x_download_options.rb +18 -31
  16. data/lib/secure_headers/headers/x_frame_options.rb +24 -32
  17. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +19 -32
  18. data/lib/secure_headers/headers/x_xss_protection.rb +17 -46
  19. data/lib/secure_headers/middleware.rb +15 -0
  20. data/lib/secure_headers/padrino.rb +1 -2
  21. data/lib/secure_headers/railtie.rb +9 -6
  22. data/lib/secure_headers/view_helper.rb +27 -44
  23. data/lib/secure_headers.rb +244 -145
  24. data/secure_headers.gemspec +7 -11
  25. data/spec/lib/secure_headers/configuration_spec.rb +80 -0
  26. data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +176 -213
  27. data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +17 -17
  28. data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +11 -43
  29. data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +13 -18
  30. data/spec/lib/secure_headers/headers/x_download_options_spec.rb +13 -17
  31. data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +15 -17
  32. data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +22 -39
  33. data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +22 -30
  34. data/spec/lib/secure_headers/middleware_spec.rb +40 -0
  35. data/spec/lib/secure_headers_spec.rb +204 -278
  36. data/spec/spec_helper.rb +32 -25
  37. data/upgrading-to-3-0.md +37 -0
  38. metadata +28 -95
  39. data/fixtures/rails_3_2_12/.rspec +0 -1
  40. data/fixtures/rails_3_2_12/Gemfile +0 -6
  41. data/fixtures/rails_3_2_12/README.rdoc +0 -261
  42. data/fixtures/rails_3_2_12/Rakefile +0 -7
  43. data/fixtures/rails_3_2_12/app/controllers/application_controller.rb +0 -4
  44. data/fixtures/rails_3_2_12/app/controllers/other_things_controller.rb +0 -5
  45. data/fixtures/rails_3_2_12/app/controllers/things_controller.rb +0 -5
  46. data/fixtures/rails_3_2_12/app/models/.gitkeep +0 -0
  47. data/fixtures/rails_3_2_12/app/views/layouts/application.html.erb +0 -11
  48. data/fixtures/rails_3_2_12/app/views/other_things/index.html.erb +0 -2
  49. data/fixtures/rails_3_2_12/app/views/things/index.html.erb +0 -1
  50. data/fixtures/rails_3_2_12/config/application.rb +0 -14
  51. data/fixtures/rails_3_2_12/config/boot.rb +0 -6
  52. data/fixtures/rails_3_2_12/config/environment.rb +0 -5
  53. data/fixtures/rails_3_2_12/config/environments/test.rb +0 -37
  54. data/fixtures/rails_3_2_12/config/initializers/secure_headers.rb +0 -17
  55. data/fixtures/rails_3_2_12/config/routes.rb +0 -4
  56. data/fixtures/rails_3_2_12/config/script_hashes.yml +0 -5
  57. data/fixtures/rails_3_2_12/config.ru +0 -7
  58. data/fixtures/rails_3_2_12/lib/assets/.gitkeep +0 -0
  59. data/fixtures/rails_3_2_12/lib/tasks/.gitkeep +0 -0
  60. data/fixtures/rails_3_2_12/log/.gitkeep +0 -0
  61. data/fixtures/rails_3_2_12/spec/controllers/other_things_controller_spec.rb +0 -83
  62. data/fixtures/rails_3_2_12/spec/controllers/things_controller_spec.rb +0 -54
  63. data/fixtures/rails_3_2_12/spec/spec_helper.rb +0 -15
  64. data/fixtures/rails_3_2_12/vendor/assets/javascripts/.gitkeep +0 -0
  65. data/fixtures/rails_3_2_12/vendor/assets/stylesheets/.gitkeep +0 -0
  66. data/fixtures/rails_3_2_12/vendor/plugins/.gitkeep +0 -0
  67. data/fixtures/rails_3_2_12_no_init/.rspec +0 -1
  68. data/fixtures/rails_3_2_12_no_init/Gemfile +0 -5
  69. data/fixtures/rails_3_2_12_no_init/README.rdoc +0 -261
  70. data/fixtures/rails_3_2_12_no_init/Rakefile +0 -7
  71. data/fixtures/rails_3_2_12_no_init/app/controllers/application_controller.rb +0 -4
  72. data/fixtures/rails_3_2_12_no_init/app/controllers/other_things_controller.rb +0 -6
  73. data/fixtures/rails_3_2_12_no_init/app/controllers/things_controller.rb +0 -5
  74. data/fixtures/rails_3_2_12_no_init/app/models/.gitkeep +0 -0
  75. data/fixtures/rails_3_2_12_no_init/app/views/layouts/application.html.erb +0 -12
  76. data/fixtures/rails_3_2_12_no_init/app/views/other_things/index.html.erb +0 -1
  77. data/fixtures/rails_3_2_12_no_init/app/views/things/index.html.erb +0 -0
  78. data/fixtures/rails_3_2_12_no_init/config/application.rb +0 -17
  79. data/fixtures/rails_3_2_12_no_init/config/boot.rb +0 -6
  80. data/fixtures/rails_3_2_12_no_init/config/environment.rb +0 -5
  81. data/fixtures/rails_3_2_12_no_init/config/environments/test.rb +0 -37
  82. data/fixtures/rails_3_2_12_no_init/config/routes.rb +0 -4
  83. data/fixtures/rails_3_2_12_no_init/config.ru +0 -4
  84. data/fixtures/rails_3_2_12_no_init/lib/assets/.gitkeep +0 -0
  85. data/fixtures/rails_3_2_12_no_init/lib/tasks/.gitkeep +0 -0
  86. data/fixtures/rails_3_2_12_no_init/log/.gitkeep +0 -0
  87. data/fixtures/rails_3_2_12_no_init/spec/controllers/other_things_controller_spec.rb +0 -50
  88. data/fixtures/rails_3_2_12_no_init/spec/controllers/things_controller_spec.rb +0 -54
  89. data/fixtures/rails_3_2_12_no_init/spec/spec_helper.rb +0 -5
  90. data/fixtures/rails_3_2_12_no_init/vendor/assets/javascripts/.gitkeep +0 -0
  91. data/fixtures/rails_3_2_12_no_init/vendor/assets/stylesheets/.gitkeep +0 -0
  92. data/fixtures/rails_3_2_12_no_init/vendor/plugins/.gitkeep +0 -0
  93. data/fixtures/rails_4_1_8/Gemfile +0 -5
  94. data/fixtures/rails_4_1_8/README.rdoc +0 -28
  95. data/fixtures/rails_4_1_8/Rakefile +0 -6
  96. data/fixtures/rails_4_1_8/app/controllers/application_controller.rb +0 -4
  97. data/fixtures/rails_4_1_8/app/controllers/concerns/.keep +0 -0
  98. data/fixtures/rails_4_1_8/app/controllers/other_things_controller.rb +0 -5
  99. data/fixtures/rails_4_1_8/app/controllers/things_controller.rb +0 -5
  100. data/fixtures/rails_4_1_8/app/models/.keep +0 -0
  101. data/fixtures/rails_4_1_8/app/models/concerns/.keep +0 -0
  102. data/fixtures/rails_4_1_8/app/views/layouts/application.html.erb +0 -11
  103. data/fixtures/rails_4_1_8/app/views/other_things/index.html.erb +0 -2
  104. data/fixtures/rails_4_1_8/app/views/things/index.html.erb +0 -1
  105. data/fixtures/rails_4_1_8/config/application.rb +0 -15
  106. data/fixtures/rails_4_1_8/config/boot.rb +0 -4
  107. data/fixtures/rails_4_1_8/config/environment.rb +0 -5
  108. data/fixtures/rails_4_1_8/config/environments/test.rb +0 -10
  109. data/fixtures/rails_4_1_8/config/initializers/secure_headers.rb +0 -17
  110. data/fixtures/rails_4_1_8/config/routes.rb +0 -4
  111. data/fixtures/rails_4_1_8/config/script_hashes.yml +0 -5
  112. data/fixtures/rails_4_1_8/config/secrets.yml +0 -22
  113. data/fixtures/rails_4_1_8/config.ru +0 -4
  114. data/fixtures/rails_4_1_8/lib/assets/.keep +0 -0
  115. data/fixtures/rails_4_1_8/lib/tasks/.keep +0 -0
  116. data/fixtures/rails_4_1_8/log/.keep +0 -0
  117. data/fixtures/rails_4_1_8/spec/controllers/other_things_controller_spec.rb +0 -83
  118. data/fixtures/rails_4_1_8/spec/controllers/things_controller_spec.rb +0 -59
  119. data/fixtures/rails_4_1_8/spec/spec_helper.rb +0 -15
  120. data/fixtures/rails_4_1_8/vendor/assets/javascripts/.keep +0 -0
  121. data/fixtures/rails_4_1_8/vendor/assets/stylesheets/.keep +0 -0
  122. data/lib/secure_headers/hash_helper.rb +0 -7
  123. data/lib/secure_headers/header.rb +0 -5
  124. data/lib/secure_headers/headers/content_security_policy/script_hash_middleware.rb +0 -22
  125. data/lib/secure_headers/version.rb +0 -3
  126. data/lib/tasks/tasks.rake +0 -48
  127. data/spec/lib/secure_headers/headers/content_security_policy/script_hash_middleware_spec.rb +0 -47
@@ -1,262 +1,441 @@
1
1
  require 'uri'
2
2
  require 'base64'
3
3
  require 'securerandom'
4
+ require 'json'
4
5
 
5
6
  module SecureHeaders
6
- class ContentSecurityPolicyBuildError < StandardError; end
7
- class ContentSecurityPolicy < Header
8
- module Constants
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
- OTHER = [
35
- :report_uri
36
- ]
37
-
38
- SOURCE_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES
39
-
40
- ALL_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES + OTHER
41
- end
42
- include Constants
43
-
44
- attr_reader :disable_fill_missing, :ssl_request
45
- alias :disable_fill_missing? :disable_fill_missing
46
- alias :ssl_request? :ssl_request
7
+ class ContentSecurityPolicyConfigError < StandardError; end
8
+ class ContentSecurityPolicy
9
+ MODERN_BROWSERS = %w(Chrome Opera Firefox)
10
+ DEFAULT_VALUE = "default-src https:".freeze
11
+ DEFAULT_CONFIG = { default_src: %w(https:) }.freeze
12
+ HEADER_NAME = "Content-Security-Policy".freeze
13
+ REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze
14
+ HEADER_NAMES = [HEADER_NAME, REPORT_ONLY]
15
+ DATA_PROTOCOL = "data:".freeze
16
+ BLOB_PROTOCOL = "blob:".freeze
17
+ SELF = "'self'".freeze
18
+ NONE = "'none'".freeze
19
+ STAR = "*".freeze
20
+ UNSAFE_INLINE = "'unsafe-inline'".freeze
21
+ UNSAFE_EVAL = "'unsafe-eval'".freeze
22
+
23
+ # leftover deprecated values that will be in common use upon upgrading.
24
+ DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze
25
+
26
+ DEFAULT_SRC = :default_src
27
+ CONNECT_SRC = :connect_src
28
+ FONT_SRC = :font_src
29
+ FRAME_SRC = :frame_src
30
+ IMG_SRC = :img_src
31
+ MEDIA_SRC = :media_src
32
+ OBJECT_SRC = :object_src
33
+ SANDBOX = :sandbox
34
+ SCRIPT_SRC = :script_src
35
+ STYLE_SRC = :style_src
36
+ REPORT_URI = :report_uri
37
+
38
+ DIRECTIVES_1_0 = [
39
+ DEFAULT_SRC,
40
+ CONNECT_SRC,
41
+ FONT_SRC,
42
+ FRAME_SRC,
43
+ IMG_SRC,
44
+ MEDIA_SRC,
45
+ OBJECT_SRC,
46
+ SANDBOX,
47
+ SCRIPT_SRC,
48
+ STYLE_SRC,
49
+ REPORT_URI
50
+ ].freeze
51
+
52
+ BASE_URI = :base_uri
53
+ CHILD_SRC = :child_src
54
+ FORM_ACTION = :form_action
55
+ FRAME_ANCESTORS = :frame_ancestors
56
+ PLUGIN_TYPES = :plugin_types
57
+
58
+ DIRECTIVES_2_0 = [
59
+ DIRECTIVES_1_0,
60
+ BASE_URI,
61
+ CHILD_SRC,
62
+ FORM_ACTION,
63
+ FRAME_ANCESTORS,
64
+ PLUGIN_TYPES
65
+ ].flatten.freeze
66
+
67
+ # All the directives currently under consideration for CSP level 3.
68
+ # https://w3c.github.io/webappsec/specs/CSP2/
69
+ MANIFEST_SRC = :manifest_src
70
+ REFLECTED_XSS = :reflected_xss
71
+ DIRECTIVES_3_0 = [
72
+ DIRECTIVES_2_0,
73
+ MANIFEST_SRC,
74
+ REFLECTED_XSS
75
+ ].flatten.freeze
76
+
77
+ # All the directives that are not currently in a formal spec, but have
78
+ # been implemented somewhere.
79
+ BLOCK_ALL_MIXED_CONTENT = :block_all_mixed_content
80
+ DIRECTIVES_DRAFT = [
81
+ BLOCK_ALL_MIXED_CONTENT
82
+ ].freeze
83
+
84
+ SAFARI_DIRECTIVES = DIRECTIVES_1_0
85
+
86
+ FIREFOX_UNSUPPORTED_DIRECTIVES = [
87
+ BLOCK_ALL_MIXED_CONTENT,
88
+ CHILD_SRC,
89
+ PLUGIN_TYPES
90
+ ].freeze
91
+
92
+ FIREFOX_DIRECTIVES = (
93
+ DIRECTIVES_2_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
94
+ ).freeze
95
+
96
+ CHROME_DIRECTIVES = (
97
+ DIRECTIVES_2_0 + DIRECTIVES_DRAFT
98
+ ).freeze
99
+
100
+ ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
101
+
102
+ # Think of default-src and report-uri as the beginning and end respectively,
103
+ # everything else is in between.
104
+ BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
105
+
106
+ VARIATIONS = {
107
+ "Chrome" => CHROME_DIRECTIVES,
108
+ "Opera" => CHROME_DIRECTIVES,
109
+ "Firefox" => FIREFOX_DIRECTIVES,
110
+ "Safari" => SAFARI_DIRECTIVES,
111
+ "Other" => CHROME_DIRECTIVES
112
+ }.freeze
113
+
114
+ OTHER = "Other".freeze
115
+
116
+ DIRECTIVE_VALUE_TYPES = {
117
+ BASE_URI => :source_list,
118
+ BLOCK_ALL_MIXED_CONTENT => :boolean,
119
+ CHILD_SRC => :source_list,
120
+ CONNECT_SRC => :source_list,
121
+ DEFAULT_SRC => :source_list,
122
+ FONT_SRC => :source_list,
123
+ FORM_ACTION => :source_list,
124
+ FRAME_ANCESTORS => :source_list,
125
+ FRAME_SRC => :source_list,
126
+ IMG_SRC => :source_list,
127
+ MANIFEST_SRC => :source_list,
128
+ MEDIA_SRC => :source_list,
129
+ OBJECT_SRC => :source_list,
130
+ PLUGIN_TYPES => :source_list,
131
+ REFLECTED_XSS => :string,
132
+ REPORT_URI => :source_list,
133
+ SANDBOX => :string,
134
+ SCRIPT_SRC => :source_list,
135
+ STYLE_SRC => :source_list
136
+ }.freeze
137
+
138
+ CONFIG_KEY = :csp
139
+ STAR_REGEXP = Regexp.new(Regexp.escape(STAR))
140
+ HTTP_SCHEME_REGEX = %r{\Ahttps?://}
141
+
142
+ WILDCARD_SOURCES = [
143
+ UNSAFE_EVAL,
144
+ UNSAFE_INLINE,
145
+ STAR,
146
+ DATA_PROTOCOL,
147
+ BLOB_PROTOCOL
148
+ ].freeze
149
+
150
+ META_CONFIGS = [
151
+ :report_only,
152
+ :preserve_schemes
153
+ ].freeze
47
154
 
48
155
  class << self
49
- def generate_nonce
50
- SecureRandom.base64(32).chomp
156
+ # Public: generate a header name, value array that is user-agent-aware.
157
+ #
158
+ # Returns a default policy if no configuration is provided, or a
159
+ # header name and value based on the config.
160
+ def make_header(config, user_agent)
161
+ header = new(config, user_agent)
162
+ [header.name, header.value]
51
163
  end
52
164
 
53
- def set_nonce(controller, nonce = generate_nonce)
54
- controller.instance_variable_set(:@content_security_policy_nonce, nonce)
165
+ # Public: Validates each source expression.
166
+ #
167
+ # Does not validate the invididual values of the source expression (e.g.
168
+ # script_src => h*t*t*p: will not raise an exception)
169
+ def validate_config!(config)
170
+ return if config.nil? || config == OPT_OUT
171
+ raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config[:default_src]
172
+ config.each do |key, value|
173
+ if META_CONFIGS.include?(key)
174
+ raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil?
175
+ else
176
+ validate_directive!(key, value)
177
+ end
178
+ end
55
179
  end
56
180
 
57
- def add_to_env(request, controller, config)
58
- set_nonce(controller)
59
- options = options_from_request(request).merge(:controller => controller)
60
- request.env[Constants::ENV_KEY] = {
61
- :config => config,
62
- :options => options,
63
- }
181
+ # Public: determine if merging +additions+ will cause a change to the
182
+ # actual value of the config.
183
+ #
184
+ # e.g. config = { script_src: %w(example.org google.com)} and
185
+ # additions = { script_src: %w(google.com)} then idempotent_additions? would return
186
+ # because google.com is already in the config.
187
+ def idempotent_additions?(config, additions)
188
+ return false if config == OPT_OUT
189
+ config.to_s == combine_policies(config, additions).to_s
64
190
  end
65
191
 
66
- def options_from_request(request)
67
- {
68
- :ssl => request.ssl?,
69
- :ua => request.env['HTTP_USER_AGENT'],
70
- :request_uri => request_uri_from_request(request),
71
- }
72
- end
192
+ # Public: combine the values from two different configs.
193
+ #
194
+ # original - the main config
195
+ # additions - values to be merged in
196
+ #
197
+ # raises an error if the original config is OPT_OUT
198
+ #
199
+ # 1. for non-source-list values (report_only, block_all_mixed_content),
200
+ # additions will overwrite the original value.
201
+ # 2. if a value in additions does not exist in the original config, the
202
+ # default-src value is included to match original behavior.
203
+ # 3. if a value in additions does exist in the original config, the two
204
+ # values are joined.
205
+ def combine_policies(original, additions)
206
+ if original == OPT_OUT
207
+ raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.")
208
+ end
73
209
 
74
- def request_uri_from_request(request)
75
- if request.respond_to?(:original_url)
76
- # rails 3.1+
77
- request.original_url
78
- else
79
- # rails 2/3.0
80
- request.url
210
+ # in case we would be appending to an empty directive, fill it with the default-src value
211
+ additions.keys.each do |directive|
212
+ unless original[directive] || !source_list?(directive)
213
+ original[directive] = original[:default_src]
214
+ end
81
215
  end
82
- end
83
216
 
84
- def symbol_to_hyphen_case sym
85
- sym.to_s.gsub('_', '-')
217
+ # merge the two hashes. combine (instead of overwrite) the array values
218
+ # when each hash contains a value for a given key.
219
+ original.merge(additions) do |directive, lhs, rhs|
220
+ if source_list?(directive)
221
+ (lhs.to_a + rhs.to_a).compact.uniq
222
+ else
223
+ rhs
224
+ end
225
+ end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives.
86
226
  end
87
- end
88
227
 
89
- # +options+ param contains
90
- # :controller used for setting instance variables for nonces/hashes
91
- # :ssl_request used to determine if http_additions should be used
92
- # :ua the user agent (or just use Firefox/Chrome/MSIE/etc)
93
- #
94
- # :report used to determine what :ssl_request, :ua, and :request_uri are set to
95
- def initialize(config=nil, options={})
96
- return unless config
228
+ private
97
229
 
98
- if options[:request]
99
- options = options.merge(self.class.options_from_request(options[:request]))
230
+ def source_list?(directive)
231
+ DIRECTIVE_VALUE_TYPES[directive] == :source_list
100
232
  end
101
233
 
102
- @controller = options[:controller]
103
- @ua = options[:ua]
104
- @ssl_request = !!options.delete(:ssl)
105
- @request_uri = options.delete(:request_uri)
106
-
107
- # Config values can be string, array, or lamdba values
108
- @config = config.inject({}) do |hash, (key, value)|
109
- config_val = value.respond_to?(:call) ? value.call(@controller) : value
110
-
111
- if SOURCE_DIRECTIVES.include?(key) # directives need to be normalized to arrays of strings
112
- config_val = config_val.split if config_val.is_a? String
113
- if config_val.is_a?(Array)
114
- config_val = config_val.map do |val|
115
- translate_dir_value(val)
116
- end.flatten.uniq
234
+ # Private: Validates that the configuration has a valid type, or that it is a valid
235
+ # source expression.
236
+ def validate_directive!(key, value)
237
+ case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[key]
238
+ when :boolean
239
+ unless boolean?(value)
240
+ raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value")
117
241
  end
242
+ when :string
243
+ unless value.is_a?(String)
244
+ raise ContentSecurityPolicyConfigError.new("#{key} Must be a string. Found #{config.class}: #{config} value")
245
+ end
246
+ else
247
+ validate_source_expression!(key, value)
118
248
  end
119
-
120
- hash[key] = config_val
121
- hash
122
249
  end
123
250
 
124
- @http_additions = @config.delete(:http_additions)
125
- @app_name = @config.delete(:app_name)
126
- @report_uri = @config.delete(:report_uri)
251
+ # Private: validates that a source expression:
252
+ # 1. has a valid name
253
+ # 2. is an array of strings
254
+ # 3. does not contain any depreated, now invalid values (inline, eval, self, none)
255
+ #
256
+ # Does not validate the invididual values of the source expression (e.g.
257
+ # script_src => h*t*t*p: will not raise an exception)
258
+ def validate_source_expression!(key, value)
259
+ unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(key)
260
+ raise ContentSecurityPolicyConfigError.new("Unknown directive #{key}")
261
+ end
262
+
263
+ unless value.is_a?(Array) && value.compact.all? { |v| v.is_a?(String) }
264
+ raise ContentSecurityPolicyConfigError.new("#{key} must be an array of strings")
265
+ end
127
266
 
128
- @disable_fill_missing = !!@config.delete(:disable_fill_missing)
129
- @enforce = !!@config.delete(:enforce)
130
- @tag_report_uri = !!@config.delete(:tag_report_uri)
131
- @script_hashes = @config.delete(:script_hashes) || []
267
+ value.each do |source_expression|
268
+ if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(source_expression)
269
+ raise ContentSecurityPolicyConfigError.new("#{key} contains an invalid keyword source (#{source_expression}). This value must be single quoted.")
270
+ end
271
+ end
272
+ end
132
273
 
133
- add_script_hashes if @script_hashes.any?
134
- fill_directives unless disable_fill_missing?
274
+ def boolean?(value)
275
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
276
+ end
135
277
  end
136
278
 
137
- ##
138
- # Return or initialize the nonce value used for this header.
139
- # If a reference to a controller is passed in the config, this method
140
- # will check if a nonce has already been set and use it.
141
- def nonce
142
- @nonce ||= @controller.instance_variable_get(:@content_security_policy_nonce) || self.class.generate_nonce
279
+
280
+ def initialize(config = nil, user_agent = OTHER)
281
+ config = Configuration.deep_copy(DEFAULT_CONFIG) unless config
282
+ @config = config
283
+ @parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
284
+ user_agent
285
+ else
286
+ UserAgent.parse(user_agent)
287
+ end
288
+ @report_only = @config[:report_only]
289
+ @preserve_schemes = @config[:preserve_schemes]
290
+ @script_nonce = @config[:script_nonce]
291
+ @style_nonce = @config[:style_nonce]
143
292
  end
144
293
 
145
294
  ##
146
295
  # Returns the name to use for the header. Either "Content-Security-Policy" or
147
296
  # "Content-Security-Policy-Report-Only"
148
297
  def name
149
- base = HEADER_NAME
150
- if !@enforce
151
- base += "-Report-Only"
298
+ if @report_only
299
+ REPORT_ONLY
300
+ else
301
+ HEADER_NAME
152
302
  end
153
- base
154
303
  end
155
304
 
156
305
  ##
157
306
  # Return the value of the CSP header
158
307
  def value
159
- return @config if @config.is_a?(String)
160
- if @config
308
+ @value ||= if @config
161
309
  build_value
162
310
  else
163
- DEFAULT_CSP_HEADER
311
+ DEFAULT_VALUE
164
312
  end
165
313
  end
166
314
 
167
315
  private
168
316
 
169
- def add_script_hashes
170
- @config[:script_src] << @script_hashes.map {|hash| "'#{hash}'"} << ["'unsafe-inline'"]
171
- end
172
-
317
+ # Private: converts the config object into a string representing a policy.
318
+ # Places default-src at the first directive and report-uri as the last. All
319
+ # others are presented in alphabetical order.
320
+ #
321
+ # Unsupported directives are filtered based on the user agent.
322
+ #
323
+ # Returns a content security policy header value.
173
324
  def build_value
174
- raise "Expected to find default_src directive value" unless @config[:default_src]
175
- append_http_additions unless ssl_request?
176
- header_value = [
177
- generic_directives,
178
- non_default_directives,
179
- report_uri_directive
180
- ].join.strip
325
+ directives.map do |directive_name|
326
+ case DIRECTIVE_VALUE_TYPES[directive_name]
327
+ when :boolean
328
+ symbol_to_hyphen_case(directive_name)
329
+ when :string
330
+ [symbol_to_hyphen_case(directive_name), @config[directive_name]].join(" ")
331
+ else
332
+ build_directive(directive_name)
333
+ end
334
+ end.compact.join("; ")
181
335
  end
182
336
 
183
- def fill_directives
184
- if default = @config[:default_src]
185
- DIRECTIVES.each do |directive|
186
- unless @config[directive]
187
- @config[directive] = default
188
- end
337
+ # Private: builds a string that represents one directive in a minified form.
338
+ # If a directive contains *, all other values are omitted.
339
+ # If a directive contains 'none' but has other values, 'none' is ommitted.
340
+ # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
341
+ #
342
+ # directive_name - a symbol representing the various ALL_DIRECTIVES
343
+ #
344
+ # Returns a string representing a directive.
345
+ def build_directive(directive_name)
346
+ return if @config[directive_name].nil?
347
+
348
+ source_list = @config[directive_name].compact
349
+ return if source_list.empty?
350
+
351
+ value = if source_list.include?(STAR)
352
+ # Discard trailing entries (excluding unsafe-*) since * accomplishes the same.
353
+ source_list.select { |value| WILDCARD_SOURCES.include?(value) }
354
+ else
355
+ populate_nonces(directive_name, source_list)
356
+
357
+ # Discard any 'none' values if more directives are supplied since none may override values.
358
+ source_list.reject! { |value| value == NONE } if source_list.length > 1
359
+
360
+ # remove schemes and dedup source expressions
361
+ unless directive_name == REPORT_URI || @preserve_schemes
362
+ source_list = strip_source_schemes(source_list)
189
363
  end
364
+ dedup_source_list(source_list).join(" ")
190
365
  end
191
- end
192
366
 
193
- def append_http_additions
194
- return unless @http_additions
195
- @http_additions.each do |k, v|
196
- @config[k] ||= []
197
- @config[k] << v
198
- end
367
+ [symbol_to_hyphen_case(directive_name), value].join(" ")
199
368
  end
200
369
 
201
- def translate_dir_value val
202
- if %w{inline eval}.include?(val)
203
- val == 'inline' ? "'unsafe-inline'" : "'unsafe-eval'"
204
- # self/none are special sources/src-dir-values and need to be quoted
205
- elsif %{self none}.include?(val)
206
- "'#{val}'"
207
- elsif val == 'nonce'
208
- self.class.set_nonce(@controller, nonce)
209
- ["'nonce-#{nonce}'", "'unsafe-inline'"]
370
+ # Removes duplicates and sources that already match an existing wild card.
371
+ #
372
+ # e.g. *.github.com asdf.github.com becomes *.github.com
373
+ def dedup_source_list(sources)
374
+ sources = sources.uniq
375
+ wild_sources = sources.select { |source| source =~ STAR_REGEXP }
376
+
377
+ if wild_sources.any?
378
+ sources.reject do |source|
379
+ !wild_sources.include?(source) &&
380
+ wild_sources.any? { |pattern| File.fnmatch(pattern, source) }
381
+ end
210
382
  else
211
- val
383
+ sources
212
384
  end
213
385
  end
214
386
 
215
- def report_uri_directive
216
- return '' if @report_uri.nil?
217
-
218
- if @report_uri.start_with?('//')
219
- @report_uri = if @ssl_request
220
- "https:" + @report_uri
221
- else
222
- "http:" + @report_uri
223
- end
224
- end
225
-
226
- if @tag_report_uri
227
- @report_uri = "#{@report_uri}?enforce=#{@enforce}"
228
- @report_uri += "&app_name=#{@app_name}" if @app_name
387
+ # Private: append a nonce to the script/style directories if script_nonce
388
+ # or style_nonce are provided.
389
+ def populate_nonces(directive, source_list)
390
+ case directive
391
+ when SCRIPT_SRC
392
+ append_nonce(source_list, @script_nonce)
393
+ when STYLE_SRC
394
+ append_nonce(source_list, @style_nonce)
229
395
  end
230
-
231
- "report-uri #{@report_uri};"
232
396
  end
233
397
 
234
- def generic_directives
235
- header_value = ''
236
- if @config[:img_src]
237
- @config[:img_src] = @config[:img_src] + ['data:'] unless @config[:img_src].include?('data:')
238
- else
239
- @config[:img_src] = @config[:default_src] + ['data:']
398
+ # Private: adds a nonce or 'unsafe-inline' depending on browser support.
399
+ # If a nonce is populated, inline content is assumed.
400
+ #
401
+ # While CSP is backward compatible in that a policy with a nonce will ignore
402
+ # unsafe-inline, this is more concise.
403
+ def append_nonce(source_list, nonce)
404
+ if nonce
405
+ if nonces_supported?
406
+ source_list << "'nonce-#{nonce}'"
407
+ else
408
+ source_list << UNSAFE_INLINE
409
+ end
240
410
  end
411
+ end
241
412
 
242
- DIRECTIVES.each do |directive_name|
243
- header_value += build_directive(directive_name) if @config[directive_name]
244
- end
413
+ # Private: return the list of directives that are supported by the user agent,
414
+ # starting with default-src and ending with report-uri.
415
+ def directives
416
+ [DEFAULT_SRC,
417
+ BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
418
+ REPORT_URI].flatten.select { |directive| @config.key?(directive) }
419
+ end
245
420
 
246
- header_value
421
+ # Private: Remove scheme from source expressions.
422
+ def strip_source_schemes(source_list)
423
+ source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
247
424
  end
248
425
 
249
- def non_default_directives
250
- header_value = ''
251
- NON_DEFAULT_SOURCES.each do |directive_name|
252
- header_value += build_directive(directive_name) if @config[directive_name]
253
- end
426
+ # Private: determine which directives are supported for the given user agent.
427
+ #
428
+ # Returns an array of symbols representing the directives.
429
+ def supported_directives
430
+ @supported_directives ||= VARIATIONS[@parsed_ua.browser] || VARIATIONS[OTHER]
431
+ end
254
432
 
255
- header_value
433
+ def nonces_supported?
434
+ @nonces_supported ||= MODERN_BROWSERS.include?(@parsed_ua.browser)
256
435
  end
257
436
 
258
- def build_directive(key)
259
- "#{self.class.symbol_to_hyphen_case(key)} #{@config[key].join(" ")}; "
437
+ def symbol_to_hyphen_case(sym)
438
+ sym.to_s.tr('_', '-')
260
439
  end
261
440
  end
262
441
  end