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