secure_headers 2.3.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 -0
  5. data/CHANGELOG.md +205 -0
  6. data/Gemfile +9 -10
  7. data/Guardfile +12 -0
  8. data/README.md +169 -301
  9. data/Rakefile +2 -36
  10. data/lib/secure_headers/configuration.rb +189 -0
  11. data/lib/secure_headers/headers/content_security_policy.rb +362 -213
  12. data/lib/secure_headers/headers/public_key_pins.rb +43 -58
  13. data/lib/secure_headers/headers/strict_transport_security.rb +21 -48
  14. data/lib/secure_headers/headers/x_content_type_options.rb +18 -32
  15. data/lib/secure_headers/headers/x_download_options.rb +18 -32
  16. data/lib/secure_headers/headers/x_frame_options.rb +24 -33
  17. data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +19 -33
  18. data/lib/secure_headers/headers/x_xss_protection.rb +17 -47
  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 +229 -162
  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 +175 -262
  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 +202 -326
  36. data/spec/spec_helper.rb +32 -28
  37. data/upgrading-to-3-0.md +37 -0
  38. metadata +14 -94
  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 -20
  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 -56
  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,292 +1,441 @@
1
1
  require 'uri'
2
2
  require 'base64'
3
3
  require 'securerandom'
4
- require 'user_agent_parser'
5
4
  require 'json'
6
5
 
7
6
  module SecureHeaders
8
- class ContentSecurityPolicyBuildError < StandardError; end
9
- class ContentSecurityPolicy < Header
10
- module Constants
11
- DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https: about: javascript:; img-src data:"
12
- HEADER_NAME = "Content-Security-Policy"
13
- ENV_KEY = 'secure_headers.content_security_policy'
14
- DIRECTIVES = [
15
- :default_src,
16
- :connect_src,
17
- :font_src,
18
- :frame_src,
19
- :img_src,
20
- :media_src,
21
- :object_src,
22
- :script_src,
23
- :style_src
24
- ]
25
-
26
- NON_DEFAULT_SOURCES = [
27
- :base_uri,
28
- :child_src,
29
- :form_action,
30
- :frame_ancestors,
31
- :plugin_types,
32
- :referrer,
33
- :reflected_xss
34
- ]
35
-
36
- OTHER = [
37
- :report_uri
38
- ]
39
-
40
- SOURCE_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES
41
-
42
- ALL_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES + OTHER
43
- CONFIG_KEY = :csp
44
- end
45
-
46
- include Constants
47
-
48
- attr_reader :disable_fill_missing, :ssl_request
49
- alias :disable_fill_missing? :disable_fill_missing
50
- 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
51
154
 
52
155
  class << self
53
- def generate_nonce
54
- 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]
55
163
  end
56
164
 
57
- def set_nonce(controller, nonce = generate_nonce)
58
- 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
59
179
  end
60
180
 
61
- def add_to_env(request, controller, config)
62
- set_nonce(controller)
63
- options = options_from_request(request).merge(:controller => controller)
64
- request.env[Constants::ENV_KEY] = {
65
- :config => config,
66
- :options => options,
67
- }
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
68
190
  end
69
191
 
70
- def options_from_request(request)
71
- {
72
- :ssl => request.ssl?,
73
- :ua => request.env['HTTP_USER_AGENT'],
74
- :request_uri => request_uri_from_request(request),
75
- }
76
- 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
77
209
 
78
- def request_uri_from_request(request)
79
- if request.respond_to?(:original_url)
80
- # rails 3.1+
81
- request.original_url
82
- else
83
- # rails 2/3.0
84
- 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
85
215
  end
86
- end
87
216
 
88
- def symbol_to_hyphen_case sym
89
- 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.
90
226
  end
91
- end
92
227
 
93
- # +options+ param contains
94
- # :controller used for setting instance variables for nonces/hashes
95
- # :ssl_request used to determine if http_additions should be used
96
- # :ua the user agent (or just use Firefox/Chrome/MSIE/etc)
97
- #
98
- # :report used to determine what :ssl_request, :ua, and :request_uri are set to
99
- def initialize(config=nil, options={})
100
- return unless config
228
+ private
101
229
 
102
- if options[:request]
103
- options = options.merge(self.class.options_from_request(options[:request]))
230
+ def source_list?(directive)
231
+ DIRECTIVE_VALUE_TYPES[directive] == :source_list
104
232
  end
105
233
 
106
- @controller = options[:controller]
107
- @ua = options[:ua]
108
- @ssl_request = !!options.delete(:ssl)
109
- @request_uri = options.delete(:request_uri)
110
-
111
- # Config values can be string, array, or lamdba values
112
- @config = config.inject({}) do |hash, (key, value)|
113
- config_val = value.respond_to?(:call) ? value.call(@controller) : value
114
-
115
- if SOURCE_DIRECTIVES.include?(key) # directives need to be normalized to arrays of strings
116
- config_val = config_val.split if config_val.is_a? String
117
- if config_val.is_a?(Array)
118
- config_val = config_val.map do |val|
119
- translate_dir_value(val)
120
- 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")
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")
121
245
  end
246
+ else
247
+ validate_source_expression!(key, value)
122
248
  end
123
-
124
- hash[key] = config_val
125
- hash
126
249
  end
127
250
 
128
- @http_additions = @config.delete(:http_additions)
129
- @app_name = @config.delete(:app_name)
130
- @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
131
266
 
132
- @disable_fill_missing = !!@config.delete(:disable_fill_missing)
133
- @enforce = !!@config.delete(:enforce)
134
- @disable_img_src_data_uri = !!@config.delete(:disable_img_src_data_uri)
135
- @tag_report_uri = !!@config.delete(:tag_report_uri)
136
- @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
137
273
 
138
- add_script_hashes if @script_hashes.any?
139
- fill_directives unless disable_fill_missing?
274
+ def boolean?(value)
275
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
276
+ end
140
277
  end
141
278
 
142
- ##
143
- # Return or initialize the nonce value used for this header.
144
- # If a reference to a controller is passed in the config, this method
145
- # will check if a nonce has already been set and use it.
146
- def nonce
147
- @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]
148
292
  end
149
293
 
150
294
  ##
151
295
  # Returns the name to use for the header. Either "Content-Security-Policy" or
152
296
  # "Content-Security-Policy-Report-Only"
153
297
  def name
154
- base = HEADER_NAME
155
- if !@enforce
156
- base += "-Report-Only"
298
+ if @report_only
299
+ REPORT_ONLY
300
+ else
301
+ HEADER_NAME
157
302
  end
158
- base
159
303
  end
160
304
 
161
305
  ##
162
306
  # Return the value of the CSP header
163
307
  def value
164
- return @config if @config.is_a?(String)
165
- if @config
308
+ @value ||= if @config
166
309
  build_value
167
310
  else
168
- DEFAULT_CSP_HEADER
311
+ DEFAULT_VALUE
169
312
  end
170
313
  end
171
314
 
172
- def to_json
173
- build_value
174
- @config.to_json.gsub(/(\w+)_src/, "\\1-src")
175
- end
315
+ private
176
316
 
177
- def self.from_json(*json_configs)
178
- json_configs.inject({}) do |combined_config, one_config|
179
- one_config = one_config.gsub(/(\w+)-src/, "\\1_src")
180
- config = JSON.parse(one_config, :symbolize_names => true)
181
- combined_config.merge(config) do |_, lhs, rhs|
182
- lhs | rhs
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.
324
+ def build_value
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)
183
333
  end
184
- end
334
+ end.compact.join("; ")
185
335
  end
186
336
 
187
- private
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?
188
347
 
189
- def add_script_hashes
190
- @config[:script_src] << @script_hashes.map {|hash| "'#{hash}'"} << ["'unsafe-inline'"]
191
- end
348
+ source_list = @config[directive_name].compact
349
+ return if source_list.empty?
192
350
 
193
- def build_value
194
- raise "Expected to find default_src directive value" unless @config[:default_src]
195
- append_http_additions unless ssl_request?
196
- header_value = [
197
- generic_directives,
198
- non_default_directives,
199
- report_uri_directive
200
- ].join.strip
201
- end
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)
202
356
 
203
- def fill_directives
204
- if default = @config[:default_src]
205
- DIRECTIVES.each do |directive|
206
- unless @config[directive]
207
- @config[directive] = default
208
- end
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)
209
363
  end
364
+ dedup_source_list(source_list).join(" ")
210
365
  end
211
- end
212
366
 
213
- def append_http_additions
214
- return unless @http_additions
215
- @http_additions.each do |k, v|
216
- @config[k] ||= []
217
- @config[k] << v
218
- end
367
+ [symbol_to_hyphen_case(directive_name), value].join(" ")
219
368
  end
220
369
 
221
- def translate_dir_value val
222
- if %w{inline eval}.include?(val)
223
- val == 'inline' ? "'unsafe-inline'" : "'unsafe-eval'"
224
- # self/none are special sources/src-dir-values and need to be quoted
225
- elsif %{self none}.include?(val)
226
- "'#{val}'"
227
- elsif val == 'nonce'
228
- if supports_nonces?(@ua)
229
- self.class.set_nonce(@controller, nonce)
230
- ["'nonce-#{nonce}'", "'unsafe-inline'"]
231
- else
232
- "'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) }
233
381
  end
234
382
  else
235
- val
383
+ sources
236
384
  end
237
385
  end
238
386
 
239
- def report_uri_directive
240
- return '' if @report_uri.nil?
241
-
242
- if @report_uri.start_with?('//')
243
- @report_uri = if @ssl_request
244
- "https:" + @report_uri
245
- else
246
- "http:" + @report_uri
247
- end
248
- end
249
-
250
- if @tag_report_uri
251
- @report_uri = "#{@report_uri}?enforce=#{@enforce}"
252
- @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)
253
395
  end
254
-
255
- "report-uri #{@report_uri};"
256
396
  end
257
397
 
258
- def generic_directives
259
- header_value = ''
260
- data_uri = @disable_img_src_data_uri ? [] : ["data:"]
261
- if @config[:img_src]
262
- @config[:img_src] = @config[:img_src] + data_uri unless @config[:img_src].include?('data:')
263
- else
264
- @config[:img_src] = @config[:default_src] + data_uri
265
- end
266
-
267
- DIRECTIVES.each do |directive_name|
268
- header_value += build_directive(directive_name) if @config[directive_name]
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
269
410
  end
411
+ end
270
412
 
271
- header_value
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) }
272
419
  end
273
420
 
274
- def non_default_directives
275
- header_value = ''
276
- NON_DEFAULT_SOURCES.each do |directive_name|
277
- header_value += build_directive(directive_name) if @config[directive_name]
278
- end
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, "") }
424
+ end
279
425
 
280
- header_value
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]
281
431
  end
282
432
 
283
- def build_directive(key)
284
- "#{self.class.symbol_to_hyphen_case(key)} #{@config[key].join(" ")}; "
433
+ def nonces_supported?
434
+ @nonces_supported ||= MODERN_BROWSERS.include?(@parsed_ua.browser)
285
435
  end
286
436
 
287
- def supports_nonces?(user_agent)
288
- parsed_ua = UserAgentParser.parse(user_agent)
289
- ["Chrome", "Opera", "Firefox"].include?(parsed_ua.family)
437
+ def symbol_to_hyphen_case(sym)
438
+ sym.to_s.tr('_', '-')
290
439
  end
291
440
  end
292
441
  end